sshelf
A fast terminal UI for managing and connecting to SSH hosts. Save each node once, then fuzzy-search and connect in two keystrokes.
sshelf keeps its own host database and generates the correct ssh command for you. It
never reads or edits ~/.ssh/config (except an explicit, read-only import). No account, no
cloud, no telemetry: your hosts live in a human-readable TOML file on your disk, secrets live
in your OS keyring, and the only network activity is the ssh it hands your terminal to.
Get started
brew install max-rh/tap/sshelf # macOS or Linux
sshelf # launch the TUI
- Install: Homebrew, shell installer,
.deb,.rpm, Gentoo, or cargo. - Quickstart: the first five minutes, adding or importing hosts, connecting.
- FAQ & troubleshooting: common questions, quick answers.
sshelf doctor: when something isn’t working, run this first.
What’s in the box
- An atuin-style fuzzy launcher with frecency ordering. Searching & connecting
- A dual-pane SFTP file browser (
Ctrl-t): mark several, send in one go,F7to create a directory. Transferring files - tmux mode, where
Enteropens each host in a background window or pane and keeps focus on the picker. Connecting inside tmux - Background port forwards that survive quitting (
Ctrl-f/F4). Port forwarding - Sites with a shared bastion and defaults, plus free-form tags (
F3). Sites & tags - Stored passwords and passphrases auto-supplied at connect, asked for and saved on a host’s first connect, and 2FA code prompts. Passwords, keys & 2FA
- SSH-config export: one
Includeline and plainssh/scp, rsync, and VS Code Remote see your hosts. Exporting to SSH config - Import from
~/.ssh/configor your whole Tailscale tailnet (--tailscale). Importing hosts - A scriptable CLI (
sshelf add,add --from-sshto save an ssh command you already have,list --json,print-command, …). CLI reference sshelf doctor, one command that checks your setup and names the fix. Checking your setup
Platforms: macOS + Linux, x86_64 and arm64. Runtime: OpenSSH 8.4+ for password auto-supply.
How it’s built
Deciding whether to trust it, or just curious how the pieces fit?
- Security & threat model: exactly what stored secrets are protected against, and what they are not.
- How the ssh command is built: argv generation and the
SSH_ASKPASSmechanism that supplies passwords withoutsshpass. - Privacy: what sshelf reads, writes, runs, and sends, in plain terms. Nothing leaves your machine.
Contributing
Questions, ideas, and feature requests belong in GitHub Discussions.
Start with CONTRIBUTING.md,
then the Development section in the sidebar: architecture, module map, data model, and the
decision log. Docs follow the docs-in-sync rule: every behavior change updates the relevant
page here in the same change, with a dated entry in the progress log.
Install
sshelf runs on macOS and Linux, x86_64 and arm64. The prebuilt packages need no Rust
toolchain; at runtime sshelf wants OpenSSH 8.4+ on your machine (password auto-supply
rides on SSH_ASKPASS_REQUIRE, added in OpenSSH 8.4; see the FAQ if unsure).
Homebrew (macOS or Linux)
brew install max-rh/tap/sshelf
Shell installer
Downloads the prebuilt binary for your platform:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-rh/sshelf/releases/latest/download/sshelf-installer.sh | sh
Debian / Ubuntu (.deb)
Grab the .deb for your architecture from the
latest release, then:
sudo apt install ./sshelf_*_amd64.deb # or *_arm64.deb
Fedora / RHEL / Rocky / openSUSE (.rpm)
The .rpm is a static build, so one package runs on any RPM distro regardless of glibc.
Grab it from the latest release, then:
sudo dnf install ./sshelf-*.x86_64.rpm # or .aarch64.rpm
Gentoo
Via the community-maintained Masterwolf overlay (unofficial; thanks to @masterwolf-git):
eselect repository enable masterwolf
emerge --sync
emerge --ask app-admin/sshelf
Cargo (crates.io)
Needs Rust 1.88+:
cargo install sshelf
After installing
- Shell tab-completion (subcommands + flags) ships with every package. Open a new shell
(or
exec $SHELL) so it loads. Completion of your saved host names takes one more line in your shell rc: see Shell completions. - On Linux, secrets use the Secret Service (GNOME Keyring / KWallet) through a pure-Rust
backend, with no
libdbusor OpenSSL packages needed. On a headless box with no keyring, see the age vault. - Next stop: the Quickstart.
Quickstart
The first five minutes with sshelf.
1. Launch
sshelf
The first run writes a commented config.toml under ~/.config/sshelf/. You land in the
(empty) host list. F1 shows every key at any time.
2. Add a host, or import the ones you already have
Press Ctrl-a: the add form opens with sensible defaults, so typing a Name and a
Hostname and pressing Ctrl-s is enough for a first host. Auth, jump hosts, tags, and
the rest are covered in Adding & editing hosts.
Already have hosts in ~/.ssh/config? Import them. The import is read-only: sshelf copies them into
its own database and never writes your config:
sshelf import --dry-run # preview what would be imported
sshelf import # do it (or press Ctrl-o in the TUI)
On a Tailscale tailnet, sshelf import --tailscale brings in every machine in one go,
with the same rules and the same read-only, add-only promise.
3. Connect
Type a few characters to fuzzy-filter, Enter to connect. sshelf records your usage and then
execs into ssh. The TUI is gone and it’s a plain ssh session; when it ends you’re
back at your shell. The hosts you use most float to the top of the idle list.
4. Connect even faster
sshelf prod-web # straight to a saved host by name, no TUI
sshelf - # reconnect to the most recently used host
5. Know where your data lives
Your hosts are one human-readable TOML file, ~/.config/sshelf/hosts.toml, safe to
hand-edit and to keep in your dotfiles. Secrets are not in it: they live in your OS
keyring or an encrypted vault (Passwords, keys & 2FA).
Where to next
- Searching & connecting:
tag:/site:filters, frecency, yanking the generated command. - Transferring files: the dual-pane SFTP browser (
Ctrl-t). - Port forwarding: background tunnels that outlive the TUI (
Ctrl-f). - Exporting to SSH config: let plain
ssh/scpand VS Code Remote use your hosts by name. - CLI reference: scripting with
add,list --json,print-command, completions.
Adding & editing hosts
Ctrl-a opens the add form; Ctrl-e edits the selected host. It’s a single screen. Every
field shows a dim placeholder explaining it (required · for Name/Hostname, optional ·
elsewhere), and it’s auth-aware: only the fields relevant to the chosen Auth method are
shown.
Quick-add: the form opens with sensible defaults, so a Name + Hostname and Ctrl-s is
enough.
Add a host from an ssh command
If you already have a working ssh line, from your history or a runbook, sshelf can read it:
sshelf add --from-ssh 'ssh -i ~/Downloads/dev-ooblek-privco.pem -o StrictHostKeyChecking=no ubuntu@44.196.235.116'
That opens this form filled in: 44.196.235.116 as the name and the hostname, ubuntu as the
user, auth key with that key file. Change what you like and save, or Esc to add nothing.
Focus starts on the name, or on the password for a password host, since a command line never
carries one. --quiet saves the host without the form. To save the command you just ran:
sshelf add --from-ssh "$(fc -ln -1)"
ssh ... | sshelf add --from-ssh can’t work: the shell runs that ssh and pipes its output.
Hand sshelf the command as text instead, as above, or with echo 'ssh ...' | in front.
The line is read with ssh’s own option rules, so -At, -p2222, -- and options after the
destination all mean what they mean to ssh. Nothing is looked up: an alias from your
~/.ssh/config stays an alias, and ssh still resolves it when you connect.
| On the line | Becomes |
|---|---|
user@host, ssh://user@host:port | hostname, user, port |
-l USER, -p PORT | user and port, over the ones in the destination |
-i KEY (repeatable) | identity files, and auth key. A relative path is saved absolute, since you’ll connect from other directories. |
-J a,b | jump hosts |
-o PasswordAuthentication=yes or -o PreferredAuthentications=password, with no -i | auth password. Those two options aren’t kept. |
anything else, e.g. -A, -L ..., -F ..., -o ServerAliveInterval=30 | extra args, in order |
A few options are dropped, each with a line saying why: -v, -q, -G, -V, -Q, -O, -S,
-E, -M, -N, -f, -n, -g, -s, and -o StrictHostKeyChecking=.... That last one
because sshelf passes accept-new on every connect and ssh keeps the first value it sees, so a
saved no would claim something that isn’t true. A remote command at the end of the line is
refused rather than dropped, since a saved host has no command, and so is anything that isn’t
ssh in front (sudo ssh ...). The name defaults to the destination’s host; when that name is
taken, give one first: sshelf add prod-web --from-ssh '...'.
Fields
Always shown: Name (required), Hostname (required), User (defaults to $USER at
connect time), Port (defaults 22), Auth, Jump hosts (ProxyJump chain, key/agent
auth only), Tags, Site, 2FA (←/→ yes/no, prompts for a verification code on
connect), Extra args (raw ssh flags appended verbatim, the escape hatch for anything the
form doesn’t model, e.g. -X or -o ServerAliveInterval=30).
Auth-specific fields:
| Auth | Extra fields |
|---|---|
agent (default) | none, ssh uses your agent/keys as usual |
key | Key: ←/→ cycles private keys found in ~/.ssh; Enter opens a file browser to pick a key anywhere. Key passphrase: optional, only if the key is encrypted |
password | Password: stored in the OS keyring / vault, never in a file |
Key discovery finds keypairs (a .pub sibling) and standalone private keys including
.pem (detected by their PRIVATE KEY header), so AWS-style keys show up too.
The file browser (from the Key field with Enter): type to fuzzy-filter, ↑/↓ move,
Enter opens a directory or selects a file, ← goes up, Backspace edits the filter (or
goes up when it’s empty), Esc clears the filter (or cancels when it’s empty). It starts in
~/.ssh (or near the current key); a picked key can live anywhere.
Navigating the form
Tab / ↑ / ↓ move between fields · ← / → (or space) change the choosers (Auth, Key,
Site, 2FA) · Enter advances and saves on the last field · Ctrl-s saves from anywhere ·
Esc cancels. Validation errors (missing name/hostname, non-numeric port) show inline, and
focus jumps to the offending field.
Secrets in the form
The masked Password / Key passphrase value goes to the OS keyring (or the age vault)
keyed by host id, never into hosts.toml. When editing, leaving the field blank keeps
the existing secret. Details: Passwords, keys & 2FA.
Deleting
Ctrl-d on the selected host asks for confirmation (y), then removes the host, its
frecency history, and its stored secret.
Prefer the command line?
Everything above can be done non-interactively with sshelf add. See
Adding hosts from the CLI. hosts.toml itself is
designed to be hand-edited too; the full schema is in Data model & files
(that’s also how you give one host multiple identity files). Each host needs an id, and any
string that’s unique in the file will do: sshelf only uses it to find the host’s secret and its
usage history. Secrets never go in the file, so a password host (or an encrypted-key host) you
wrote by hand has nothing stored. Its first connect asks for the secret and saves it once it
works; see saving the secret on first connect.
Searching & connecting
The list screen is a fuzzy launcher in the style of atuin: the search box is always active, so plain typing filters the list, and actions use Ctrl or function keys.
Filtering
- Type to fuzzy match against your hosts; matched characters are highlighted.
tag:NAMEkeeps only the hosts with that tag. Repeat it and combine it with text (tag:prod tag:dbis an AND).site:NAMEkeeps only the hosts in that site.
Ordering
- Idle (no query): hosts sort by frecency, usage count decayed by recency, so your
daily drivers sit at the top. The decay rate is configurable, and
default_sort = "name"opts out entirely (Configuration). The idle list also groups by site (── site (n) ──headers,(no site)last). - Filtering: best fuzzy match first; frecency breaks ties. The list is flat, with a dim
·site·column.
Keys
| Key | Action |
|---|---|
| type | filter the list (fuzzy text, tag: / site: tokens) |
↑ / ↓, Ctrl-p / Ctrl-n | move the selection |
Enter | connect to the selected host |
Ctrl-a / Ctrl-e / Ctrl-d | add / edit / delete a host |
Ctrl-y | yank: copy the generated ssh command without connecting |
Ctrl-t | transfer files to/from the selected host |
Ctrl-f | port-forward through the selected host |
Ctrl-o | import from ~/.ssh/config (read-only) |
F1 | help overlay with every key, in the TUI itself |
F2 | settings: hosts file, tmux mode (Configuration) |
F3 | manage sites |
F4 | manage port forwards |
Esc | clear the query if non-empty, otherwise quit |
Ctrl-c | quit |
What “connect” actually does
Enter records the host’s usage (for frecency), tears the TUI down, and execs into
ssh. sshelf is replaced by the real ssh process, so there is no wrapper between you
and your session, and when the session ends you’re back at your shell. The command it runs is
exactly what Ctrl-y (or sshelf print-command <host>) shows: plain flags built from the
host’s fields plus any inherited site defaults, with no temporary config files.
A host with nothing stored may ask for its secret on the terminal first, and keeps it once it
works (Saving the secret on first connect).
Full mechanics: How the ssh command is built.
Connecting inside tmux
By default connecting always hands the terminal over, tmux or not. Set tmux to "window" or
"pane" (Configuration, or F2) and, when sshelf is itself running
inside tmux, Enter instead opens the connection in a new tmux window (named after the
host) or a new pane, in the background, and leaves you in the picker with focus still on it.
You can fire off four hosts in a row without reopening sshelf between them, then switch to each
window when you want it. A one-line status confirms each one
(opened in tmux window: prod-web).
Outside tmux, or with tmux = "off", nothing changes: Enter is exactly the handoff described
above.
Hosts that always connect in place
Four kinds of connection step back to the normal handoff even in tmux mode, and say so on the line just before ssh starts:
-
A host with nothing stored that’s about to ask for its first secret: the question has to be asked on this terminal, and a new window has no sshelf in it to ask. The line reads
no secret stored yet, connecting here so the first one can be saved. -
2FA hosts: the verification code you typed can only reach a new tmux window through
tmux new-window -e KEY=VALUE, which is the tmux client’s own command line and therefore visible inps. sshelf will not put a one-time code there. -
Stored-password hosts in vault mode (with
$SSHELF_VAULT_PASSPHRASEset), for the same reason: the master passphrase would have to cross the same boundary. -
tmux older than 3.0, which has no
-eat all, for hosts with a stored secret.
Everything else (key, agent, and keyring-backed password hosts) opens in tmux normally. Only the askpass wiring crosses (including the host’s opaque id, which the helper trades for the secret); no secret ever does. The reasoning is D-025.
Connecting without the TUI
sshelf prod-web # connect by name (or id), same path as Enter
sshelf - # reconnect to the most recently used host
A miss suggests the closest matching names; a host named like a subcommand (list,
import, …) is reached via the TUI instead. The rest of the CLI:
CLI reference.
Transferring files
Ctrl-t on a host opens a dual-pane transfer screen: your local files on one side, the
host’s on the other. Mark what you want and send it in either direction over SFTP, with fuzzy
search on both sides, live progress, and a F7 to create directories without leaving.
sshelf authenticates once: it opens an ssh ControlMaster that reuses the host’s normal
auth (keys/agent/ProxyJump, or the stored password, supplied the same way as on connect) and
runs sftp over it. No per-file re-prompts, and ~/.ssh/config is never touched. Remote
listing and transfers run on a background thread, so the UI stays responsive on slow links.
Keys
| Key | Action |
|---|---|
| type | filter the focused pane |
Tab | switch the focused pane (local ↔ remote) |
↑ / ↓, Ctrl-p / Ctrl-n | move the selection |
Space | mark / unmark the selected file or folder |
Ctrl-a | mark everything the filter shows; press again to clear every mark |
Ctrl-s | send the marked entries (or, with none marked, the selected one) into the other pane’s directory |
F7 / Ctrl-f | create a directory in the focused pane |
→ / Enter | open the selected directory (on a file: send it) |
← | go up a directory |
Backspace | edit the filter, or go up when it’s empty |
Esc | cancel a running transfer, else clear marks, else clear the filter, else close the screen |
Marking and sending several at once
Space marks the entry under the cursor; marked rows get a • and the accent color, and the
pane title counts them. Ctrl-s then sends all of them, files and folders alike and folders
recursively, into the other pane’s current directory, one at a time through the same single
authenticated connection. The progress line counts through the batch (2 of 5 report.pdf → deploy@host).
- Marks are positional. Changing directory, refreshing a listing, or a listing error drops
them; they are never remembered per path.
Escclears them explicitly. - Sending consumes the marks; the queue becomes the record of what’s going.
- An entry the destination already has is skipped and the queue carries on; the summary
names what was passed over (
sent 3 of 4 · skipped dup.txt (already there)). A real transfer failure stops the rest, since whatever broke will usually break the next one too, and the status says how many were left unsent. Spacemarks rather than typing a space into the filter. Filenames containing spaces still match by the rest of their name.
Creating a directory (F7)
F7 (or Ctrl-f, if your terminal keeps F7 for itself) opens a one-line input at the bottom
of the focused pane. Type a name, Enter creates it in that pane’s current directory,
Esc cancels. It works on both sides; the remote one goes through the same SFTP connection.
The name must be a single directory name: no / (this creates one directory, not a path), no
control characters, not . or .., and not a name that already exists. An existing
directory is never adopted, so the input stays open with an error and you can pick another
name. On success the listing refreshes and the new directory lands under the cursor.
Hidden files
Both panes list hidden entries. The local pane always did; the remote one lists with
ls -lan through sftp, so dotfiles and dot-directories show up on the server side too and
you can open .config or .ssh the same way you open any other directory. . and ..
are never listed on either side (← goes up).
There is no show/hide toggle. If a directory has too much in it, type a . into the pane
filter: that keeps the names with a dot in them, and the hidden ones sort to the top.
Behavior & limits
- Directories are shown as
name/and symlinks asname@, and symlinks are skipped. - A same-named file or folder already present in the destination is skipped (with a message), never overwritten. What that promise rests on differs between a file and a folder, so it is spelled out under What “never overwritten” covers below.
- One transfer runs at a time: a batch is a queue, not parallel copies. Single-file downloads
show bytes + percent; folders and uploads show as in-flight (cancelable with
Esc, which abandons the rest of the queue too). - Filenames are shell-quoted (spaces are fine) and control characters are stripped from display.
- A remote listing is given 60 seconds and a remote
mkdir30 seconds before sshelf kills thesftprunning it, and the pane saystimed out after 60s listing <path>rather than sitting there. A listing is also capped at 16 MiB of output and 50,000 entries; past that the pane sayslisting truncated at 50000 entriesinstead of passing a partial directory off as the whole of it. Closing the screen never waits on any of this:Escgets the terminal back even if the server has stopped answering. - The connection uses
StrictHostKeyChecking=accept-new, like connect: a first-time host key is trusted on first use, a changed key still hard-fails. See Security. - Renaming, deleting, changing permissions, and overwriting are not in this version.
What “never overwritten” covers
A single file never replaces anything, sent in either direction. The bytes land on a private
.sshelf-part-… name in the destination directory first, and the finished file is put in place
with a link, which fails if any name is already there. A symlink counts as a name, and it is
never followed, so nothing can redirect the write. If the name turned up while the transfer was
running, the entry is skipped exactly as the pre-flight check would have skipped it, and the
queue carries on.
On an upload that link is made by the server, through the hardlink@openssh.com extension
OpenSSH has carried since 5.7. Two kinds of destination cannot take one. A server that does not
offer the extension is the first; a filesystem with no hard links at all is the second, which
locally means exFAT and FAT32 (what a USB stick usually is) and a fair number of SMB and FUSE
mounts. In both cases sshelf checks that the name is still free and then moves the temporary
onto it, which is a smaller window than writing the final name directly but not the same
guarantee. The bytes are never thrown away over it.
A folder is checked against the last listing of the destination and nothing more, whichever way it is going. A directory cannot be installed from a temporary with a link, so there is nothing better to be had. That listing is the one the pane is showing: sshelf asks the server for a fresh one immediately before each upload, so a folder queued behind another item is checked against a listing taken moments earlier, while the first item of a send is checked against whatever the pane last showed, which can be minutes old. A file that appears in the destination after that listing can be overwritten. If that matters for what you are sending, look at the destination first.
A remote directory past the 50,000-entry cap is the one case where the check cannot be made at all, so sshelf refuses to send a folder into it rather than guess:
the destination listing is incomplete (cut at 50000 entries) — sshelf can't promise not to overwrite there
Single files are fine there, in both directions, since they do not rely on the listing.
Where the connection lives
The screen holds one ssh ControlMaster, and its control socket lives in a directory sshelf
creates for that session with mode 0700: $XDG_RUNTIME_DIR/sshelf/mux-<ulid>/m.sock when
XDG_RUNTIME_DIR is set, otherwise ~/.local/share/sshelf/run/mux-<ulid>/m.sock. It used to be
a predictable name straight in /tmp, where another account on the machine could take the path
first. Both the socket and the directory are removed when the screen closes. A stray mux-*
directory from a crash is harmless and can be deleted.
When it can’t connect
The screen opens one connection and cannot prompt for anything: sshelf is still holding the
terminal, so a prompt from ssh would land on top of the TUI with no way to type into it. So
the connection is opened with BatchMode=yes whenever there is no stored secret to supply,
which means it fails quickly and says why instead of hanging (see D-032).
In practice a key host works here if the key has no passphrase, or if the key is already loaded
in your agent (ssh-add -l to check). If it isn’t, you get “could not authenticate: if that
key needs a passphrase, load it with ssh-add or save it on the host with ^e”. Do either
and reopen the screen. A password host needs its password stored (^e), and a host that needs a
verification code can’t open a transfer screen yet.
Debugging a failing transfer
The status line shows the underlying sftp error. For the full story:
sshelf --transfer-log ~/.local/share/sshelf/transfer.log # or $SSHELF_TRANSFER_LOG
This appends every ssh and sftp command, the local and remote paths they touch, their
stderr, and every value the host’s extra_args contributes. No password is logged: a stored
secret reaches ssh via SSH_ASKPASS and never the command line. Taken together, though, that
is a full description of the connection, so keep the file somewhere private. sshelf creates it
mode 0600 and refuses to follow a symlink at that path (you get one line on stderr and no log),
which is why the example points inside the data directory rather than at /tmp.
Port forwarding
Ctrl-f on a host starts an SSH tunnel that keeps running after you quit sshelf. Set it
up, close the TUI (or the whole terminal), and it stays up until you stop it or it drops.
Creating a forward (Ctrl-f)
Pick a kind. ←/→ cycle it while the Type row is selected; Tab and ↑/↓ move
between rows, and the hint at the bottom of the popup always shows what the current row does:
- Local (
-L, the default) is a local port that tunnels to something reachable from the server. E.g. reach the server’s private database as127.0.0.1:8080on your machine. - Remote (
-R) is a port on the server that tunnels back to something reachable from your machine. E.g. let someone on the server’s network reach a dev server on your laptop. - Dynamic (
-D) is a local SOCKS proxy that routes traffic through the server.
Fill in the ports and host (the defaults are bind 127.0.0.1 and target host localhost),
then press Ctrl-s. sshelf spawns a detached ssh -N ... reusing the host’s auth exactly as
connect does (keys/agent/ProxyJump, stored password, site defaults), then waits
briefly to confirm the tunnel actually bound. A failure is shown in the popup so you can fix a field
and retry:
- local port already in use, so pick another port;
- privileged port: ports below 1024 need root, so use 1024 or higher;
- server refused the remote bind: the server’s
sshdcontrols remote binds (GatewayPorts); - authentication failures. A forward is a detached process with no terminal of its own, so
like the transfer screen it runs with
BatchMode=yeswhen there is no stored secret and fails instead of stopping on a prompt nobody could answer (see D-032). A key with a passphrase has to be in your agent (ssh-add) or stored on the host (^e); - DNS failures, reported as-is.
On success you’re back at the list and the tunnel runs on its own.
The forwards manager (F4)
Lists every active forward across all hosts: the host, a summary like
L 127.0.0.1:8080 → db:3306, the pid, and the age.
| Key | Action |
|---|---|
↑ / ↓, Ctrl-p / Ctrl-n | move the selection |
d (or k), then y | stop the selected forward |
Esc / Ctrl-s / Ctrl-c | close the manager |
The list refreshes live and is reconciled against the actually-running processes: a
forward that ends (stopped here, killed from another terminal, or dropped on its own after
sleep or network loss) disappears within a moment, and on every launch sshelf shows only
forwards that are still really up. The ledger lives in forwards.json
(data model), but the processes are authoritative; the file is just
remembered PIDs. Design details: decisions.md, D-021.
Why they survive
Each forward is its own detached process in its own process group, with no tie to sshelf or
your terminal: quitting sshelf orphans it (fine), and closing the terminal doesn’t hang it
up. Stop one from F4, or kill <pid> works too; sshelf notices either way.
Sites & tags
Two ways to organize hosts:
- Tags are free-form, many per host (
prod,db,web). They are pure labels: filter withtag:NAME, repeatable and ANDed. - A site is one per host (a data center, a project, a customer). Sites group the idle
list and filter with
site:NAME, and they can optionally carry shared SSH defaults that member hosts inherit.
Site defaults & inheritance
A site may define a default user, port, jump host(s) (the site’s bastion), and identity file(s). At connect time each member host is resolved against them:
- the site’s value fills in only where the host leaves that field unset, so the host always wins;
- auth is never inherited and stays per-host;
- a bare site (name only) is pure grouping.
Inherited defaults show up everywhere the command does: connect, Ctrl-y yank,
sshelf print-command, transfers, forwards. A host that names an undefined site still
groups under that name and just inherits nothing.
The user@host:port you see is the resolved one, so a host with no user of its own is
listed under the site’s user in the TUI, in sshelf list, and in shell completion, and
searching for that user finds it. What hosts.toml stores is unchanged, and so is
sshelf list --json: an inherited field stays null there, with the resolved values
visible in the generated command.
In the list
Idle (empty search box): hosts group under ── site (n) ── headers, with (no site) last.
While filtering: a flat list with a dim ·site· column; site:NAME narrows to one site.
Managing sites (F3)
a add · e/Enter edit · d delete · Ctrl-s save · Esc cancel. Each site’s form is a
name plus the optional defaults. Renaming a site updates its member hosts; deleting
one clears its members’ site, so nothing dangles. Assign a host’s site in the
add/edit form (←/→ over the defined sites + (none)).
From the CLI
sshelf sites # sites, member counts, their defaults
sshelf sites --json # machine-readable
sshelf sites add prod-dc -u deploy -J bastion.prod # define a site with shared defaults
sshelf add web1 -H 10.0.0.4 --site prod-dc # add a host into it
sshelf list site:prod-dc # filter by site
Storage: [[site]] entries in hosts.toml. See Data model & files.
Passwords, keys & 2FA
Prefer SSH keys / agent where you can. Password storage exists for hosts you can’t use keys with; it is the least secure option sshelf offers. The full threat model: Security.
Auth methods
Each host uses one auth method, chosen in the add/edit form:
agent(default): ssh uses your keys/agent as usual and sshelf stores nothing.key: one or more-iidentity files. If the key is encrypted, you can store its passphrase and sshelf supplies it automatically at connect.password: sshelf stores the login password and supplies it automatically.
Where secrets live
- The OS keyring (default): macOS Keychain, or the Secret Service on Linux (GNOME Keyring /
KWallet). Service
sshelf, keyed by host id. - The age vault (headless): if
SSHELF_VAULT_PASSPHRASEis set, secrets go to anage-encrypted file (vault.age, mode0600) instead, which is the path for servers and CI with no keyring daemon. The tradeoffs are documented in Security.
Never in hosts.toml, never on a command line, never in logs or shell history.
How auto-supply works
On connect, sshelf points SSH_ASKPASS at itself and execs ssh. When ssh needs the
secret it invokes that helper, which answers only genuine password/passphrase prompts
(matched by their shape) and declines everything else, so a hostile server can’t phish the
secret with a look-alike prompt, and the secret never appears in ps or on disk. The full
mechanics, and why this needs OpenSSH 8.4+, are in
How the ssh command is built.
Storing & changing a secret
- On the first connect: a host with nothing stored asks for it and keeps it once it works (below).
- In the form: the masked Password / Key passphrase field. When editing, blank keeps the existing secret.
- From a script or a headless box:
echo "$PASS" | sshelf set-password prod-db # store or replace after the fact
echo "$PASS" | sshelf add legacy -H 10.0.0.9 -u root --password-stdin
Deleting a host removes its stored secret too.
Saving the secret on first connect
A password host with nothing stored, or a key host whose key needs a passphrase, asks for it on the terminal when you connect. In the TUI that happens after the list is gone and before ssh starts:
Password for ubuntu@44.196.235.116 (saved to your keyring once it works; Enter to skip):
It reads with echo off (vault instead of keyring when you use the vault). Then:
- The answer is stored right away, because the askpass helper reads it from the store and nothing else can get it to ssh without putting it in argv.
- sshelf proves it with one throwaway
ssh ... exitagainst the host. Exit status 255 is ssh’s own failure; anything else means ssh got in and ranexit. - If it worked, you see
saved password for <name>and the real connect goes ahead. Every later connect goes straight in. - If the server refused it, the secret is removed again, sshelf prints
the password was refused by <user@host>; nothing saved, and exits 1. Any other failure (unreachable, no answer in 30 seconds) also removes it: a secret that couldn’t be checked isn’t kept.
Enter on the empty prompt skips. The connect runs exactly as before and ssh asks for itself.
Nothing is remembered, so the next connect asks again, until something is stored. Esc or
Ctrl-C backs out without connecting. There’s no setting to turn the question off; skipping is
the way out.
A key host gets one more step first. Each identity file is checked with ssh-keygen -y, and only
a key that needs a passphrase counts. sshelf then tries the host once with BatchMode=yes. If
your agent, or an unencrypted key next to the encrypted one, already gets you in, nothing is asked
and nothing is stored. Only a refusal from the server leads to the prompt. If the host can’t be
reached at all, the connect goes ahead and ssh shows the real error, since no passphrase would fix
that.
A first connect costs one extra handshake, two for an encrypted key. Every connect after it is the same as before.
Agent hosts, hosts that already have a secret, and a host behind two or more jump hosts are never
asked. The last kind wires no helper at all, so there’s nothing to save into, and ssh asks on the
terminal as it always has. sshelf print-command and sshelf list --json never ask either.
2FA hosts are saved without the check, because checking would use up the code the host is about
to ask for. The password comes first and the code second, since the code is the one that goes
stale, and the line says it wasn’t checked:
saved password for <name> (not checked: this host needs a code; if the login fails, press ^e in the TUI or run sshelf set-password <name>). In the TUI, a 2FA host that’s about to be asked
skips the code popup, and both questions come on the terminal in that order.
In tmux mode, a host that would be asked connects in place instead of in a new window, because the question needs this terminal.
When a stored secret is wrong
ssh asks again after a refused password or passphrase, and the helper would hand over the same wrong value every time until ssh gave up, which reads like the server turning you away. So the helper notices when ssh asks the same question twice in one connect, which only happens after a refusal, and says so:
sshelf: the stored password for 01J9ZK... was refused; replace it with sshelf set-password or ^e in the TUI
Then it declines, so ssh stops retrying with that value. The stored secret is not deleted: a
server can ask twice for reasons of its own, and a helper that deleted on a repeat could throw
away a correct one. Replace it with ^e or sshelf set-password <name>. The transfer screen and
port forwards say the stored password was refused when they fail this way.
Two-factor (2FA) hosts
Some servers ask for a verification code (TOTP / keyboard-interactive) on top of your key or
password. Set 2FA = yes on the host (form, or sshelf add ... --2fa):
- TUI connect: a popup collects the current code before the ssh handoff and feeds it to the server’s verification prompt through the same askpass channel. sshelf never proxies the live session.
- CLI connect (
sshelf <host>): prompts for the code on the terminal.
The code is masked either way. The popup shows one bullet per character, like the password
field in the host form, and the terminal prompt reads with echo off so the code never lands in
scrollback or a screen recording. Esc or Ctrl-C backs out without connecting. If stdin is a
pipe rather than a terminal, the old line read is used instead, so a script can still feed the
code in.
Codes are manual entry: sshelf does not store TOTP seeds. The flag exists because a
connect that auto-supplies a stored secret runs ssh with SSH_ASKPASS_REQUIRE=force, which
routes the code prompt to the helper with no terminal fallback. Unflagged, such a
connect fails at the code prompt. (A host with no stored secret is asked for one on its first
connect, above; a host that combines an encrypted key with
2FA is better served by the agent.) Background: decisions.md, D-022.
Limitations worth knowing
- Jump hosts must use key/agent auth. The askpass helper only holds the target’s secret and can’t tell which hop is prompting, so with a secret in play sshelf either constrains a single hop to key/agent auth explicitly or hands it no helper at all. See the FAQ.
- A key host is connected with
PreferredAuthentications=publickey(pluskeyboard-interactivewhen it needs a code), so a server cannot fall back to asking for a password. If one of your key hosts relied on that fallback, add the password as a second host or change that host’s auth to password. - OpenSSH prints at most 100 characters of a key’s path in its passphrase prompt, and the helper only answers a prompt that names one of the host’s key files exactly. A passphrase for a key whose full path is longer than that can’t be supplied, and the first connect doesn’t ask for one. Keep that key in your agent, or move it somewhere with a shorter path.
- Building from source on macOS: an unsigned binary may trigger a Keychain approval prompt on connect (Keychain ACLs are keyed to the code signature). See the FAQ.
Importing hosts
sshelf can populate its database from two sources you already have: your SSH config and your Tailscale tailnet. Both are read-only towards their source and add-only towards sshelf: a host whose name already exists is left alone, so re-running is always safe.
From ~/.ssh/config
sshelf import (or Ctrl-o in the TUI) copies hosts from ~/.ssh/config into sshelf’s own
database. It is strictly read-only: sshelf parses the file and never writes back. Your
SSH config is not touched, ever.
sshelf import --dry-run # preview what would be imported
sshelf import # import
What it does:
- adds every host whose name isn’t already present in sshelf, so re-running is safe and existing names are left alone;
- carries over the fields sshelf models (hostname, user, port, identity files);
- warns about the directives it doesn’t import (
Match,Include,ProxyJump) instead of silently mis-importing them.
From Tailscale
sshelf import --tailscale imports your tailnet: every machine in it becomes a
searchable sshelf host, in one command.
sshelf import --tailscale --dry-run # preview
sshelf import --tailscale # import
It runs your own tailscale CLI (tailscale status --json) and reads the output. sshelf
still makes no network calls of its own: nothing happens unless you run this command, never
at startup, never on a save, never in the background, and there’s no Tailscale entry point in
the TUI. Your API keys and tailnet credentials stay with the Tailscale client; sshelf never
sees them, and nothing tailscale-specific is written to hosts.toml.
Which machines are imported. Every peer whose MagicDNS name is under your tailnet’s own
domain, one rule that leaves out Mullvad exit nodes and machines shared in from other
tailnets. Peers with an expired node key are skipped. Offline peers are imported: being
asleep is temporary, and the host is still real. This machine (Self) is not imported.
What each machine becomes:
| sshelf field | From the peer |
|---|---|
name | First label of the MagicDNS name, lowercased (e.g. nas.tail4f9a2.ts.net. → nas). |
hostname | The MagicDNS FQDN (nas.tail4f9a2.ts.net), stable across IP churn, and what Tailscale SSH expects. If MagicDNS is off for your tailnet, its Tailscale IP (IPv4 first). |
site | Your tailnet’s name, matched to an existing site case-insensitively, or created as a bare one (name only, no defaults). |
tags | The machine’s ACL tags, minus the tag: prefix (tag:server → server). Untagged machines get no tags. |
auth | agent, your key/agent auth, unchanged. Add a user, port or password afterwards if a box needs one. |
Two machines whose names collide (possible only across sub-domains) keep the first; the second is reported. Everything skipped is counted in a warning line, so the numbers always add up.
If sshelf can’t find the CLI. It looks at $SSHELF_TAILSCALE_BIN, then tailscale on your
PATH, then /Applications/Tailscale.app/Contents/MacOS/Tailscale, since the macOS app doesn’t put
its CLI on PATH. For any other location:
SSHELF_TAILSCALE_BIN=/path/to/tailscale sshelf import --tailscale
The import needs the Tailscale backend to be running (tailscale up); if it isn’t, sshelf
says so and changes nothing.
After importing
Import brings everything in at once (there’s no per-host picker); curate afterwards with
Ctrl-e / Ctrl-d, and organize with tags or sites. Your ~/.ssh/config
keeps working exactly as before, and sshelf’s database is independent of it by design (the
FAQ explains why).
The reverse direction exists too: export projects your sshelf hosts back out
as an Include fragment, so plain ssh/scp can use them by name, including everything a
tailnet import just added.
Exporting to SSH config
sshelf export makes your sshelf hosts available to everything else: it writes an
ssh_config fragment to sshelf’s own file and you add a single Include line to your
~/.ssh/config yourself. sshelf never edits that file (or anything under ~/.ssh).
sshelf export
# Exported 14 host(s) to ~/.config/sshelf/ssh_config
# To use it, add this line to your ~/.ssh/config (sshelf never edits that file):
# Include ~/.config/sshelf/ssh_config
Once included, sshelf’s database stops being a walled garden: your hosts resolve by name in any tool that reads SSH config:
ssh prod-web # plain ssh, no sshelf in the loop
scp report.pdf prod-web:/tmp/ # scp / sftp
rsync -av ./site/ prod-web:/var/www/ # rsync (runs over ssh)
git clone prod-web:/srv/repo.git # git's ssh transport
The same goes for anything with an SSH-config picker: VS Code Remote-SSH lists your sshelf
hosts in its host dropdown, JetBrains Gateway and similar tools likewise. The jump host, port,
user, and identity file all come along, so ssh prod-web through the site’s bastion just
works.
Staying fresh
Creating the file once (running sshelf export) is the opt-in: from then on sshelf
rewrites it automatically every time your hosts change: add/edit/delete in the TUI,
sshelf add, an import, a site change. Edit a host’s bastion in sshelf and VS Code picks it
up on its next connect. (Delete the file to opt back out; sshelf export --stdout prints the
fragment without writing anything.)
The output is deterministic (hosts sorted by name, no timestamps), so the file only changes when your database does. Diff-friendly if you keep it alongside dotfiles.
What gets exported
Per host (with its site defaults resolved, exactly like connect):
HostName, User, Port (when not 22), IdentityFile (key-auth hosts; ~ left for ssh to
expand), and ProxyJump. From Extra args, -o Key=Value options translate to real
config directives; other raw flags (-X and the like) can’t be expressed in config and are kept
visible as a comment in the host’s block instead of being guessed at.
Worth knowing:
- Where sshelf’s entries win. ssh uses the first value it finds for an option, so put
the
Includeat the top of~/.ssh/configif sshelf’s entries should win for same-named hosts, or at the bottom if your hand-written entries should. - Password-auth hosts export fine, but plain
sshcan’t read sshelf’s keyring, so it prompts on the terminal. Auto-supply (and the 2FA popup) remain sshelf-connect features. - No behavior smuggling. sshelf’s own connects pass
StrictHostKeyChecking=accept-new(an askpass necessity); the export deliberately does not, so your plain ssh keeps your own host-key defaults. - Names that can’t be Host patterns (containing
*,?,!,,,#, or quotes) are skipped with a comment, since they’d otherwise match other hostnames. Names with spaces are quoted and work.
Round-tripping with import is symmetric on purpose: import copies your SSH
config in (read-only), export projects the database out (to its own file). Your
~/.ssh/config is never written by either. Design notes: decisions.md,
D-023.
CLI reference
Everything sshelf does without opening the TUI.
Commands
| Command | What it does |
|---|---|
sshelf | Launch the interactive TUI. |
sshelf <host> | Connect straight to a saved host by name or id, skipping the TUI, on the same connect path as Enter (frecency recorded, stored secret auto-supplied or asked for and saved on the first connect, 2FA code prompted on the terminal). A miss suggests the closest names; a host named like a subcommand (list, import, …) is reached via the TUI instead. |
sshelf - | Reconnect to the most recently used host. Errors (without connecting) if there’s no history yet. |
sshelf add [NAME ...] | Bare: open the TUI add form. With arguments: add a host non-interactively; see below. With --from-ssh: build the host from an ssh command line; see below. |
sshelf list [query] [--json] | List hosts (with a ·site· column). query filters with the TUI’s syntax: fuzzy text and/or tag:NAME / site:NAME (e.g. sshelf list site:prod-dc). The user@host:port column shows site defaults resolved. |
sshelf print-command <host> | Print the generated, shell-quoted ssh ... command (site defaults included) without connecting or touching frecency, the CLI twin of Ctrl-y. |
sshelf sites [--json] | List defined sites with member counts + their shared defaults. |
sshelf sites add NAME [-u/-p/-J/-i] | Define a site (settings optional; edit later with F3). |
sshelf import [--dry-run] | Read-only import from ~/.ssh/config. |
sshelf import --tailscale [--dry-run] | Import your Tailscale tailnet: runs your own tailscale status --json and adds every eligible peer (MagicDNS name → host, tailnet → site, ACL tags → tags). Add-only; re-running adds 0. |
sshelf export [--stdout] | Export the database as an ssh_config Include fragment, written next to sshelf’s config (--stdout prints it instead). Once the file exists, it refreshes on every hosts change. |
sshelf set-password <host> | Store a password / key passphrase for a host, read from stdin. |
sshelf doctor | Check this machine and your host database: the OpenSSH version, the secret backend, dangling site references, a stale export, a missing ssh-agent. Local and read-only; it never contacts a host. Exits 1 if any check failed (warnings don’t). |
sshelf completions <shell> | Print static shell completions. |
sshelf man | Print the man page. |
Global flags work before or after a subcommand (sshelf --config F list and
sshelf list --config F are the same command). --config FILE uses a specific config file
(also $SSHELF_CONFIG); see Configuration. --transfer-log FILE appends
transfer diagnostics, no secrets, to FILE (also $SSHELF_TRANSFER_LOG); see
Transferring files.
A host name and a subcommand can’t be combined: sshelf prod-web list is refused rather than
quietly running list and forgetting the host.
sshelf <host> can ask up to two things on the terminal before it hands over to ssh. A host with
nothing stored asks for its password or key passphrase and saves it once a throwaway login proves
it works (details); Enter skips. A
2FA host then asks for its code. Both read with echo
off. When stdin is a pipe they read plain lines instead, one per question and in that order, so
a script connecting to a password host with nothing stored needs one extra line on stdin, the
same way a 2FA code does.
Plain output replaces terminal control characters. Host names, users, hostnames, tags and site
names come out of hosts.toml, an imported ~/.ssh/config or a tailnet, and any of those can
be crafted: an escape sequence in one of them could move the cursor or forge the lines after
it. Every human-readable field sshelf list, import, add, sites, doctor and the shell
completions print goes through a filter first, which turns control characters, C1 codes and
bidirectional overrides into U+FFFD. The add form and the importers refuse such a name outright,
and an existing hosts.toml still loads: those values are cleaned up on display, not rejected.
Environment: $SSHELF_TAILSCALE_BIN names the tailscale binary --tailscale should run,
for installs that aren’t on PATH (sshelf otherwise tries PATH, then the macOS app bundle
at /Applications/Tailscale.app/Contents/MacOS/Tailscale). $SSHELF_VAULT_PASSPHRASE uses
the age vault instead of the OS keyring; see
Passwords, keys & 2FA.
Adding hosts from the CLI
sshelf add with arguments adds a host non-interactively (handy for scripts and dotfiles).
NAME and --hostname are required:
# key auth (a -i identity implies --auth key)
sshelf add prod-web -H 10.25.25.10 -u deploy -i ~/.ssh/id_ed25519 -t prod,web
# jump host + custom port
sshelf add prod-db -H 10.25.25.25 -u mike -p 5432 -J bastion.example.net -i ~/.ssh/db
# agent auth (the default) with extra raw ssh flags
sshelf add edge -H edge.example.net --extra "-o ServerAliveInterval=30"
# password auth: pipe the secret in (kept out of argv/history; stored in the keyring or vault)
echo "$PASS" | sshelf add legacy -H 10.0.0.9 -u root --password-stdin
| Flag | Meaning |
|---|---|
NAME (positional) | Alias to search/connect by. Required. |
-H, --hostname <HOST> | IP or DNS name. Required. |
-u, --user <USER> | Login user (defaults to $USER at connect time). |
-p, --port <PORT> | SSH port (default 22). |
-a, --auth <agent|key|password> | Auth method. Inferred as key with --identity, password with --password-stdin, else agent. |
-i, --identity <PATH> | Identity file for key auth (repeatable). |
-J, --jump <HOST> | ProxyJump host (repeatable or comma-separated). Key/agent auth only. |
-t, --tag <TAG> | Tag (repeatable or comma-separated). |
-s, --site <NAME> | Assign the host to a site. |
--2fa | Mark the host as needing a verification code on connect. |
--extra "<ARGS>" | Extra raw ssh flags, appended verbatim. |
--password-stdin | Read a password / key passphrase from stdin and store it. |
A duplicate name is refused. To store a secret after the fact, use
sshelf set-password <name>, or let the first connect ask for it.
From an ssh command line
--from-ssh builds the host from a working ssh command instead of flags:
sshelf add --from-ssh 'ssh -i ~/Downloads/dev.pem -p 2222 ubuntu@203.0.113.10'
sshelf add prod-web --from-ssh "$(fc -ln -1)" # the ssh command you just ran
echo 'ssh -J bastion deploy@10.0.0.5' | sshelf add --from-ssh
It opens the add form with the fields filled in, so what’s left to type is the secret, if the
host has one. -q/--quiet saves the host without the form and prints the same added line a
flag-built add does, after one note: line for anything the parser dropped. What maps to which
field, and what gets dropped, is in
Adding & editing hosts.
| Flag | Meaning |
|---|---|
--from-ssh [CMD] | The ssh command as one quoted string. No value, or -, reads one line from stdin. Can’t be combined with --hostname, --user, --port, --auth, --identity, --jump or --extra, which the line already supplies. |
-q, --quiet | With --from-ssh: add without opening the form. Refused without --from-ssh. |
NAME, --tag, --site, --2fa and --password-stdin still apply. Put NAME before
--from-ssh, since the flag takes the next word as its value. With no value and stdin on a
terminal, sshelf stops and shows both ways to pass the command instead of waiting for input.
When --from-ssh and --password-stdin both read stdin, the command line is the first line and
the secret is the second:
printf '%s\n%s\n' 'ssh -p 2222 ops@legacy.example' "$PASS" |
sshelf add legacy --from-ssh --password-stdin --quiet
Without --quiet, a secret read that way is put in the form’s secret field. If there is no
terminal to open the form on at all, the host is added as if --quiet were given, with a note
saying so.
Shell completions
Static completion (subcommands + flags) ships with every package. Open a new shell after
installing so it loads. It’s also printed by sshelf completions <shell>.
Dynamic completion (sshelf prod<Tab> completing your saved host names) takes one
line in your shell rc:
source <(COMPLETE=bash sshelf) # bash: add to ~/.bashrc
source <(COMPLETE=zsh sshelf) # zsh: add to ~/.zshrc (after compinit)
COMPLETE=fish sshelf | source # fish: add to ~/.config/fish/config.fish
Host-name completion works for direct connect, print-command, and set-password.
JSON output
sshelf list --json [query] emits each selected host’s fields plus its generated ssh
command, and is always valid JSON even when the selection is empty, the stable surface for
scripts and integrations. sshelf sites --json does the same for sites.
The host fields are the record as stored in hosts.toml, so a user, port, jump host or
identity file inherited from a site stays null or empty there. The resolved
values are in command.
JSON output is not filtered the way the plain output is: the serializer already escapes
control characters, and a script needs the value exactly as it is stored. command is built as
if no secret were stored for the host, so a listing never reads your keyring once per host;
sshelf print-command and Ctrl-y do resolve the secret and can therefore differ for a host
that has both a stored secret and a jump host. See
SSH command generation.
sshelf doctor has no --json: its exit code (0 healthy, 1 something failed) is the
scriptable part, and a machine-readable report has no users yet (D-027).
Configuration
config.toml
Lives at ~/.config/sshelf/config.toml (honors XDG_CONFIG_HOME) and is written with
comments on first run:
| Key | Default | Meaning |
|---|---|---|
decay_rate | 0.2 | Frecency decay per day (higher = recency matters more). |
default_sort | "frecency" | Idle list order: "frecency" or "name". |
accent | "cyan" | UI accent color: black / red / green / yellow / blue / magenta / cyan / white / gray. |
hosts_file | (config dir) | Custom host-database path; ~ is expanded. Editable from F2. |
tmux | "off" | Inside tmux, where Enter opens a connection: "off", "window", or "pane". See Searching & connecting. Editable from F2. |
Point sshelf at an alternate config file with --config FILE or $SSHELF_CONFIG. The
config-file location itself isn’t a key in the file (that would be circular). The hosts
file location is a setting.
That file’s directory becomes sshelf’s config directory, and sshelf will create it at mode 0700
if it isn’t there. It will not change the permissions of one that already exists: pointing
--config at a file in a directory you share with other tools or other people leaves that
directory exactly as you set it. The default ~/.config/sshelf is sshelf’s own and is still
held at 0700 on every run. sshelf doctor tells you if a config directory is group-writable,
world-writable, or not owned by you.
The settings screen (F2)
Tab moves between fields; Enter or Ctrl-s saves, Esc cancels.
- Config file: shown read-only (it’s chosen before the config is read; see above).
- Hosts file: editable; blank means the default under the config dir. On save, an existing file at the new path is adopted (loaded, never overwritten); a new path is created from your current hosts, so they follow.
- tmux:
Spacecyclesoff→window→pane. Only has an effect when sshelf itself is running inside tmux.
Where everything lives
XDG paths on macOS and Linux (~/.config / ~/.local/share, honoring
XDG_CONFIG_HOME / XDG_DATA_HOME):
| File | Default location | What it is |
|---|---|---|
hosts.toml | ~/.config/sshelf/ | The host database: human-readable, hand-editable, nice to keep in dotfiles. |
config.toml | ~/.config/sshelf/ | The preferences above. |
state.json | ~/.local/share/sshelf/ | Frecency counters. App-managed; churns. |
forwards.json | ~/.local/share/sshelf/ | Ledger of active port forwards. App-managed. |
vault.age | ~/.local/share/sshelf/ | Encrypted secret store, only in vault mode. |
Secrets are never in hosts.toml; it’s keyring or vault only. All writes are atomic
(temp file + rename), so a crash mid-write can’t corrupt your files.
Hand-editing hosts.toml is supported, and that’s also how you give one host multiple
identity files. The full schema: Data model & files.
sshelf doctor
sshelf doctor
One command that checks the things that quietly break connections (the OpenSSH version, the secret backend, a site that no longer exists, a stale export, a missing agent) and tells you, per check, what to do about it. If something isn’t working, run this first.
sshelf doctor — checking this machine and your host database
ok ssh — OpenSSH 9.8 (8.4+, so stored secrets can be supplied)
ok host database — 12 host(s), 2 site(s) in ~/.config/sshelf/hosts.toml
ok secrets — the OS keyring is reachable
fail sites — 1 host(s) point at a site that isn't defined: web → "prod-dc"
→ Define it with `sshelf sites add prod-dc`, or clear the field with Ctrl-e — until
then those hosts inherit nothing from it.
ok orphaned secrets — not checked (the OS keyring can't be listed; vault mode can)
warn ssh-agent — $SSH_AUTH_SOCK is not set, but 3 host(s) use agent auth: web, db, edge
→ Start an agent (`eval $(ssh-agent)`, or your desktop's) and `ssh-add` your key —
otherwise those hosts fall back to whatever else ssh can find.
ok ssh_config export — up to date (~/.config/sshelf/ssh_config)
1 failed, 1 warning(s), 5 ok
Every line is ok, warn, or fail, and anything that isn’t ok carries one runnable next
action on its own line.
warnmeans it works today, but it limits something or will bite later.failmeans it’s broken now.
Exit code: 0 when nothing failed (warnings are fine), 1 if any check failed. So
sshelf doctor && deploy.sh does what you’d expect, and CI can gate on it.
What it checks
| Check | Fails when | Warns when |
|---|---|---|
| ssh | ssh -V reports older than OpenSSH 8.4, or ssh can’t be run | the version string can’t be parsed |
| host database | hosts.toml doesn’t parse, or two hosts share a name or an id | never |
| secrets | the configured backend can’t be used (no keyring service; an unreadable vault) | never |
| sites | a host’s site names a site that isn’t defined | never |
| orphaned secrets | never | a stored secret belongs to no host (vault mode only, see below) |
| ssh-agent | never | $SSH_AUTH_SOCK is unset, or points at a socket that’s gone, while hosts use agent auth |
| ssh_config export | never | the exported fragment no longer matches your hosts |
| config directory permissions | never | the config directory is group-writable, world-writable, or owned by someone else (unix only) |
Two notes on the edges:
- Duplicate ids are a failure, not a nitpick. The id keys both the stored secret and the frecency history, so two hosts sharing one share a password and a usage count.
- Orphaned secrets can only be found in vault mode. The OS keyring offers no portable way to list what’s in it, so the check says it didn’t run rather than reporting a clean bill of health nobody checked. In vault mode the vault is a map sshelf owns, so the ids are right there.
What it does not do
- It never contacts a host. No pings, no test connections, no version checks over the
network. sshelf makes no network calls of its own (Security) and
doctoris no exception: it tells you whether sshelf is set up, never whether a server is up. - It never changes anything. Every check is a read, with exactly one exception: in keyring mode it writes a clearly-named throwaway entry and deletes it again, because a backend you can read but not write is a backend that fails the first time you save a password.
- It doesn’t fix anything for you. Each not-ok line names the command or the key to press; you decide.
- No
--json. Human-readable output only for now, see D-027.
When to reach for it
- A password host prompts you instead of connecting → the ssh and secrets checks.
- A host connects but ignores its site’s bastion or user → the sites check.
ssh <name>works from sshelf but not from your shell → the export check.- Key auth suddenly asks for a passphrase → the ssh-agent check.
- Anything at all after hand-editing
hosts.toml→ the host database check.
FAQ & troubleshooting
Why doesn’t sshelf just use my SSH config?
By design. ~/.ssh/config is often shared, load-bearing infrastructure (Ansible, Terraform,
and your editor’s remote mode all read it), and a tool that rewrites it can corrupt all of that.
sshelf keeps an independent database and builds ssh commands from it with plain flags;
its only contact with your SSH config is the explicit, read-only import. (ssh
itself still reads your config normally when sshelf launches it; sshelf just never writes
it.) It isn’t a one-way street either: sshelf export generates an Include
file so plain ssh/scp, and anything that reads SSH config like VS Code Remote, can use
your sshelf hosts by name.
What does sshelf need at runtime?
OpenSSH 8.4+ on your machine. Password and passphrase auto-supply ride on
SSH_ASKPASS_REQUIRE, added in OpenSSH 8.4 (2020). Key/agent hosts work with anything
reasonably modern. Platforms: macOS + Linux, x86_64 and arm64.
Run sshelf doctor. It reads your version and says whether it’s enough.
Does sshelf phone home?
No. No telemetry, no account, no network calls of its own. The only network activity is the
ssh/sftp it runs for you. See Security.
Then how does the Tailscale import work?
The same way: by running a program you already have.
sshelf import --tailscale executes your tailscale CLI
(tailscale status --json) and parses its output. sshelf opens no sockets, holds no API key,
and talks to no Tailscale server. It only ever runs when you type that command: never at
startup, on a save, on a timer, or from the TUI. Nothing tailscale-specific (node IDs, keys) is
written to hosts.toml, and the import only ever adds hosts.
Password auto-supply isn’t working
- Built from source on macOS? An unsigned binary can hit a Keychain approval prompt on
every connect (Keychain ACLs are keyed to the code signature). Approve it, or ad-hoc sign
your build:
codesign -s - target/release/sshelf.
Start with sshelf doctor: it checks the OpenSSH version and round-trips your
secret backend, which covers both of the usual causes.
I’m on a headless box with no keyring
Set SSHELF_VAULT_PASSPHRASE and secrets then live in an age-encrypted vault file instead
of a keyring. Details: where secrets live; the
env-inheritance tradeoff is documented in Security.
sshelf doctor confirms which backend it’s actually using and whether it opens.
Can a jump host use password auth?
No, and since 0.14.0 that is enforced rather than only written down. Jump hosts are key/agent only, because the askpass helper holds the target’s secret and can’t tell which hop in a chain is prompting.
With one jump host and a stored secret (or a queued verification code), sshelf replaces -J
with an explicit ProxyCommand that runs the hop with BatchMode=yes and both password and
keyboard-interactive auth off, so the hop can only use an agent or an unencrypted key file.
With two or more hops there is no way to constrain them individually, so sshelf wires no helper
at all: ssh asks for the target’s secret on the terminal, and you see
multi-hop jump with a stored secret: ssh will ask for it on the terminal before it does.
In tmux mode that connection opens in place instead of in a new window, because a new window
has no terminal to ask on.
One thing that has not changed: the hop checks its own host key against your
~/.ssh/known_hosts, and ssh does not pass the destination’s -o options down to it. That
was already true of plain -J, so a bastion you have never connected to has to be accepted once
by connecting to it directly first.
If a jump-host connection fails, check your agent is reachable: sshelf doctor.
Can I open connections in tmux windows instead of leaving the picker?
Yes. Set tmux = "window" (or "pane") in
config.toml, or cycle the field on the F2 settings screen. When sshelf
is running inside tmux, Enter then opens the host in a new window named after it and keeps
the picker up, so you can fire off several in a row. Outside tmux the setting does nothing.
Why did my 2FA host not open a tmux window?
Because the verification code would have to travel as tmux new-window -e KEY=VALUE, the tmux
client’s own command line, which anyone on the machine can read with ps. sshelf refuses to
put a one-time code there, so those hosts connect in place instead, printing
2FA host — connecting here before the handoff. The same applies to stored-password hosts in
vault mode (the master passphrase would cross the same
boundary), and to tmux older than 3.0, which has no -e at all. A host with nothing stored
that’s about to ask for its first secret connects in place too, printing
no secret stored yet, connecting here so the first one can be saved, because the question needs
this terminal. Key, agent, and keyring-backed password hosts open in tmux normally. Details:
Connecting inside tmux.
Can I send more than one file at a time?
Mark them: Space toggles a mark in the
transfer screen, Ctrl-a marks everything
the filter shows, and Ctrl-s sends the lot, folders included and recursively, one at a time
over the one connection. F7 (or Ctrl-f) creates a directory on either side without leaving
the screen.
My 2FA host fails before I can type the code
Flag it: 2FA = yes in the edit form (or --2fa on sshelf add). A stored-secret connect
routes all prompts to the askpass helper with no terminal fallback, so the verification
prompt needs the 2FA flow to answer it.
Tab completion doesn’t complete my host names
Completion has two layers. The packages install static completion (subcommands + flags), so open a new shell to load it. Completing your saved host names needs the dynamic engine sourced in your shell rc, one line per shell: Shell completions.
If host names still don’t complete, check the database itself parses:
sshelf doctor.
A forward vanished from F4
F4 only ever shows forwards whose processes are actually running: the list is
reconciled against the OS on launch and refreshed live while open. If the tunnel died (
reboot, sleep, network drop, killed from another terminal), it leaves the list; start it
again with Ctrl-f. Automatic re-launch of dropped forwards isn’t there yet.
How do I back up or sync my hosts?
hosts.toml is one human-readable TOML file, so keep it in your dotfiles like any config (a
custom path is a setting: Configuration). Secrets don’t travel with
it: they’re per-machine, in each machine’s keyring or vault. On the new machine each host
asks for its secret on its first connect and keeps it once it works, or you can store them up
front with sshelf set-password. Frecency state is per-machine and app-managed.
On the new machine, run sshelf doctor. It checks the file parses, has no
duplicate names or ids, and that its sites all exist.
I copied hosts.toml to a new machine and it asks for passwords
That’s the first connect saving them. Secrets never live in hosts.toml, so nothing is stored
on the new machine yet, and each password host (or key host with an encrypted key) asks once,
checks the answer against the server, and keeps it. After that it goes straight in. Enter at
the prompt skips, and sshelf set-password <name> stores one without connecting.
How it works.
If you use the vault (SSHELF_VAULT_PASSPHRASE), vault.age can be copied over along with
hosts.toml, and with the same passphrase the secrets come with it, since both are keyed by host
id. A keyring entry doesn’t move that way.
Can I generate hosts.toml from an inventory?
Yes. The schema is in Data model & files. Each host needs an id, a name and
a hostname, and the id can be any string that’s unique in the file, so an inventory key works.
Run sshelf doctor on the result: it checks the file parses and has no duplicate
names or ids. Leave secrets out. The first connect to each host asks for its secret and keeps it
once it works.
Where did the first-connection host-key prompt go?
Connections pass StrictHostKeyChecking=accept-new: a brand-new host’s key is accepted and
recorded on first use (so the prompt can’t interfere with automated password supply), while a
changed key for a known host still hard-fails, as ever. The tradeoff is discussed in
Security.
Windows?
Not currently. Connect hands off via Unix exec(), and the askpass/process plumbing is
Unix-specific. macOS + Linux for now.
Something isn’t working and I don’t know why
Run sshelf doctor. It checks the OpenSSH version, your secret backend, the host
database, dangling site references, the ssh-agent, and the exported ssh_config fragment, and
names a fix for anything that isn’t right. It’s local and read-only, and it never contacts a host.
My question isn’t here
Ask in GitHub Discussions. Questions, ideas, and feature requests all belong there, and answers that come up often end up on this page.
Security & threat model
sshelf stores SSH passwords so it can auto-supply them. This document states exactly what
that protects against and what it does not. The shipped root SECURITY.md (M8) is a
user-facing summary of this.
Strongly prefer SSH keys / agent over stored passwords. Password storage exists for hosts you can’t use keys with; it is the least secure option
sshelfoffers.
Where secrets live
- The OS keyring, the primary store: macOS Keychain (
security-framework) or Linux Secret Service over D-Bus (keyringcrate). Servicesshelf, account = hostid. - The
agevault (opt-in, headless): ifSSHELF_VAULT_PASSPHRASEis set, secrets go in the XDG data dir asvault.age, encrypted with that passphrase (agepassphrase mode = scrypt KDF + ChaCha20-Poly1305). This is the path for headless Linux with no Secret Service daemon, and for automation/CI. v1 reads the passphrase from the env var (deterministic, scriptable); an interactive prompt + auto-detection of a missing keyring are future enhancements. Env-inheritance tradeoff: the askpass helper runs as ssh’s child and reads the env var to decrypt the vault, so for hosts with a stored secret the passphrase is necessarily in the ssh process tree’s environment (/proc/<pid>/environ, same-user readable). For hosts with no stored secret,ssh.rs::configure_askpassstrips the variable before the exec. - Provisioning:
sshelf set-password <name|id>stores a secret from stdin (so it can be piped in headless setups) without going through the TUI. - Never in
hosts.toml,state.json, logs, shell history, or process arguments.
The host-key id is the lookup key in both stores, so renaming a host keeps its secret.
How the password reaches ssh
Via SSH_ASKPASS: ssh calls the helper, which prints the secret on stdout. The password
is never passed as a CLI argument (no sshpass -p), so it never appears in ps/argv. See
ssh-command.md.
The prompt the helper is handed is server-controlled on a keyboard-interactive round, so
matching its shape is not by itself enough: Password: is a well-shaped prompt that any host
can send. The connect therefore also tells the helper which secret it is holding, and the
helper answers only that one:
- A password host answers a login-password prompt and nothing else.
- A key host answers only OpenSSH’s own
Enter passphrase for key '<path>':, and only when<path>is one of the identity files that connect actually passed with-i. APassword:from the server gets nothing. - A secret-shaped prompt of the wrong kind is declined, and is never answered with a queued verification code instead.
- A key host also connects with
-o PreferredAuthentications=publickey(pluskeyboard-interactivewhen the host needs a verification code), so a server cannot steer it into a password prompt in the first place. - Host-key confirmations never arrive, because connect passes
StrictHostKeyChecking=accept-new.
A jump hop is a child of ssh and inherits the same environment, and OpenSSH does not forward
the destination’s -o options to it. So whenever the helper is wired, a single jump host is
replaced by an explicit ProxyCommand with BatchMode=yes, PasswordAuthentication=no and
KbdInteractiveAuthentication=no, which leaves the hop with an agent or an unencrypted key
and nothing else. A chain of two or more hops cannot be constrained that way, so the helper is
not wired at all and ssh asks for the target’s secret on the terminal. Details and the
rejected alternatives: ssh-command.md
and D-029.
Saving a secret on first connect
A connect to a host with nothing stored can ask for the secret on the terminal
(details). The answer is read with echo off
into a zeroizing buffer and stored in the keyring or vault before it is checked, because the
askpass helper reads the store and there is no other way to get the secret to ssh without argv.
The check is one ssh ... exit wired exactly like a connect. If the server refuses the secret,
or the check can’t finish, the secret is deleted again before sshelf exits. 2FA hosts are stored
without a check, since a check would use up the code. The ssh-keygen -y and the probe that come
first never see the secret at all.
Rejected: letting the helper learn the secret from ssh’s own prompt (that would give the helper write access to the store, in the one place the prompt text comes from the server), and storing without a check (a typo would be supplied on every later connect). See D-035.
A refused stored secret
The helper leaves one marker per connect, askpass-<connect id>, created exclusively at mode
0600 in sshelf’s private runtime directory. It holds the prompt the helper answered and nothing
secret. When ssh asks the same question again in the same connect, the stored secret was refused:
the helper prints one line saying so and declines, instead of handing over the same wrong value on
every retry. The connect id is an opaque ULID in the child’s environment and never crosses tmux’s
argv.
The helper never deletes the secret. A server can ask twice for reasons of its own, for example
offering keyboard-interactive and password where the first fails on the server’s side, and a
helper that deleted on a repeat could destroy a correct secret. The user replaces it on the
strength of the message. Details: ssh-command.md.
The tmux boundary
With tmux = "window"/"pane" (Searching & connecting),
a connection is opened by a new tmux window, which is a child of the tmux server and
inherits nothing from sshelf’s own process. tmux’s only channel is new-window -e KEY=VALUE, and
those pairs are the tmux client’s argv: readable by anyone on the machine with ps. That is
exactly the leak SSH_ASKPASS exists to avoid, so the rule is content-based:
- Only the askpass wiring ever crosses:
SSH_ASKPASS,SSH_ASKPASS_REQUIRE=force,SSHELF_ASKPASS=1,SSHELF_HOST_ID,SSHELF_SECRET_KIND, and (for key hosts)SSHELF_IDENTITY_FILES. The id is an opaque ULID; the helper trades it for the secret in the keyring, exactly as it does after anexec(). The kind is one of three words and the identity files are paths you can already read inhosts.toml. No value there is a secret. SSHELF_2FA_CODEandSSHELF_VAULT_PASSPHRASEnever cross. A connection that needs either (a 2FA host, or a stored-secret host in vault mode) falls back to the in-placeexec()handoff, where the environment is passed byfork/execand never appears in argv. A unit test asserts neither variable can appear in a generated tmux argv.- Key/agent hosts pass no environment at all.
- The ssh argv is handed to tmux as separate arguments after
--, so tmuxexecvps it directly rather than letting a shell re-parse it.
Rationale and rejected alternatives: D-025.
Threat model
Protected against
- On-disk plaintext exposure: secrets are in the OS keyring or encrypted at rest in the vault.
- Process-listing / argv leakage: the password is delivered via stdin/stdout to
ssh, not argv; the tmux path (above) is held to the same rule, falling back rather than bending it. - Shell-history leakage:
sshelfnever echoes the command containing a password. - Casual file snooping: the vault requires the master passphrase (memory-hard KDF).
hosts.tomlsharing: it contains no secrets, so it’s safe to commit/share/back up.- Config-file corruption: atomic writes, so a crash mid-write leaves the prior file intact.
NOT protected against (out of scope)
- A root/admin attacker or malware on the machine, which can read process memory, the keyring,
or keystrokes.
sshelfassumes you trust your own machine. - Keyloggers, which can capture the master passphrase as you type it.
- A compromised OS keyring daemon; sshelf trusts the platform’s secret service.
- Physical theft without full-disk encryption. Use FDE, which is an OS-level control.
- Unencrypted backups / cloud sync of
vault.age. The vault is encrypted, but treat it as sensitive and don’t rely on it as your only protection in an untrusted backup.
Assumption: sshelf targets a developer/operator’s own (trusted) machine, not shared or
hostile hosts.
Operational notes
- No password recovery. Forgetting the vault master passphrase means losing vault secrets. Use a passphrase you can recover (e.g. from another password manager).
- macOS unsigned builds: the re-exec’d askpass child reading Keychain can trigger an OS approval prompt on each connect (Keychain ACLs are keyed to code signature). Ad-hoc sign dev builds; release builds should be signed.
StrictHostKeyChecking=accept-newtrusts a new host’s key on first connect but still hard-fails if a known host’s key changes (MITM protection retained).- Network:
sshelfmakes no network connections of its own and has no telemetry; it only ever launches the OpenSSH tools:sshto connect, andssh/sftpfor the file-transfer screen. On a first connect with nothing stored it can also runssh-keygen -yon the host’s key file and one throwawayssh ... exitagainst the host you are connecting to. Transfers authenticate exactly as connect does (keys/agent, or the stored secret viaSSH_ASKPASS) by opening one multiplexedsshControlMaster and runningsftpover it, so there is no extra secret handling and the secret still never reaches argv. Remote paths are quoted forsftp’s parser, control characters are stripped from displayed names, andStrictHostKeyChecking=accept-newapplies there too. The optional transfer log (--transfer-log/$SSHELF_TRANSFER_LOG) records thessh/sftpcommands and their stderr for troubleshooting, and it contains no secrets (same reason: the password goes via askpass).
Reporting
(M8) Add a SECURITY.md at the repo root with a disclosure contact before public release.
SSH command generation & the askpass mechanism
This is the heart of sshelf and its trickiest part. Read carefully before touching ssh.rs
or askpass.rs.
1. Building the ssh argv
From a Host, build (in order):
ssh
[-i <identity_file>]... # one -i per entry in identity_files (auth = "key")
[-p <port>] # only if port present and != 22
[-J <jump1,jump2,...>] # ProxyJump chain (jump_hosts), comma-joined
| -o ProxyCommand=... # instead of -J, for one hop with a secret to protect (§3a)
-o StrictHostKeyChecking=accept-new # see §3: keeps host-key prompt away from askpass
[-o PreferredAuthentications=publickey[,keyboard-interactive]] # key hosts only (§3)
<extra_args...> # raw, split with `shlex`, appended verbatim
<user>@<hostname> # user defaults to $USER if unset
- Pure flags only, with no temporary
ssh -Fconfig files (keeps the “never touch SSH config” promise literal and avoids cleanup). extra_argsis the escape hatch for anything the wizard doesn’t model (-X,-L ...,-o ...). Split withshlex::splitso quoted args survive.- Example: stored host
mike@10.25.25.25with key~/.ssh/infra-key→ssh -i /home/mike/.ssh/infra-key -o StrictHostKeyChecking=accept-new mike@10.25.25.25in the printed/yanked command (the exec path expands~internally as well).
The same builder backs the Ctrl-y yank action and sshelf print-command <host>
(copy/print the exact command without connecting). For copy/paste safety, identity-file ~
is expanded before shell-quoting; quoted ~ would not expand in the user’s shell.
Both resolve the host’s stored secret first, so what you copy is the command sshelf would
actually run, including the ProxyCommand a jump host gets when a secret is in play (§3a).
sshelf list --json is the exception: its command field is built as if no secret were
stored, because a listing must not read the keyring once per host. That is also the right
answer for a command you run by hand, which has no askpass helper for a hop to inherit.
2. Launch handoff (exec)
On connect:
- Persist frecency first (
exec()never returns, so nothing runs after it). - Set environment for the child:
SSH_ASKPASS = <path to sshelf's own binary>(std::env::current_exe())SSH_ASKPASS_REQUIRE = forceSSHELF_ASKPASS = 1← how the re-exec’d binary knows it’s in askpass modeSSHELF_HOST_ID = <id>← which secret to fetchSSHELF_SECRET_KIND = password | passphrase | agent← which secret that id holds (§3)SSHELF_IDENTITY_FILES = <path>[:<path>...]← key hosts only,~already expanded (§3)SSHELF_CONNECT_ID = <ulid>← fresh for every wired command, so the helper can spot a repeated prompt (§3b). An id, nothing secret.env_remove("SSH_ASKPASS")of any inherited value first, then set ours (avoid pollution).
- Tear down the TUI:
disable_raw_mode()→LeaveAlternateScreen→ show cursor → flush. std::os::unix::process::CommandExt::exec()intossh. If it returns, it errored → restore terminal, show the error.
A RAII guard + panic hook guarantees step 3’s teardown also runs on panic/early-exit.
2a. The tmux handoff
With tmux = "window"/"pane" and $TMUX present, steps 3 and 4 are replaced by a spawn and
sshelf keeps running:
tmux new-window|split-window
-d # open it in the background
[-e SSH_ASKPASS=<self>] [-e SSH_ASKPASS_REQUIRE=force]
[-e SSHELF_ASKPASS=1] [-e SSHELF_HOST_ID=<id>] # only when a secret is stored
[-e SSHELF_SECRET_KIND=...] [-e SSHELF_IDENTITY_FILES=...]
[-n <host name>] # new-window only; split-window has no -n
-- # ends tmux's own options
ssh <the argv from §1, as separate arguments>
- Frecency is still persisted first; the spawn is as much a point of no return as
exec(). -dcreates the window or pane without switching to it, so focus stays on the picker and the next host is one keypress away.SSHELF_CONNECT_IDis never passed, so a tmux window gets no repeated-prompt marker (§3b).- The argv is passed as separate arguments, never one joined string, so tmux
execvps it and a path containing a space survives. -epairs land in the tmux client’s argv, so only non-secret wiring may ride there. A queued 2FA code, a vault master passphrase, or a tmux older than 3.0 (no-e) sends the connection back to theexec()path above, with the reason printed once the TUI is down. Seesecurity.mdand D-025.- So does a host with nothing stored that the first connect would ask for its secret (§2b):
no secret stored yet, connecting here so the first one can be saved. Only the part of the trigger that needs no network is checked in the TUI; the probe runs after teardown.
2b. First connect: ssh-keygen, the probe, and the verify
A connect to a host with nothing stored may ask for its secret first
(Passwords, keys & 2FA). It runs after the
frecency save and before the 2FA prompt and the exec(), and uses up to three kinds of child
process, each with stdin closed, stderr captured, and a deadline:
ssh-keygen -y -P '' -f <identity file> # key hosts, per -i, 5s
ssh -o BatchMode=yes -o ConnectTimeout=15 <argv from §1> exit # the probe: encrypted key only, 30s
ssh -o ConnectTimeout=15 <argv from §1> exit # the verify: helper wired, 30s
ssh-keygenexiting 0 means the key has no passphrase. A non-zero exit whose stderr mentionspassphrasemeans it has one. Anything else (a missing or unreadable file) means sshelf doesn’t ask and lets ssh report the problem. A key whose~-expanded path is longer than 100 bytes is never asked about: OpenSSH prints the path as'%.100s', so the helper’s exact match in §3 could never answer its prompt.- The probe is built unwired, the same as a connect with nothing stored. Any exit status but 255
means the agent or another key already gets in, and the normal connect runs with nothing asked.
255 with
Permission deniedleads to the prompt. On a 2FA key host only a denial that still listspublickeycounts, because a key the server accepted still ends inPermission denied (keyboard-interactive)whenBatchModecan’t send the code. Any other 255 connects normally and lets ssh show the error. - The secret is stored before the verify, and the verify is wired exactly like the real connect,
the §3a jump rules included, so the secret reaches ssh only through the helper. 255 with
Permission deniedis a refusal; any other 255 or the deadline is a failure. Both remove the secret again and exit 1. Any other status is the remoteexit, so the secret worked. 2FA hosts skip the verify. - The
-ooptions come first so they beat anything in the host’sextra_args; ssh keeps the first value it is given. - A host whose jump chain takes the terminal path (§3a) is never asked, since there is no helper to save into.
3. Secret auto-supply and the sharp edges
Applies whenever a stored secret exists for the host: a login password (password
auth) or a key passphrase (key auth with an encrypted key). exec_connect wires the
askpass env only when such a secret exists (wire_askpass); otherwise ssh prompts / uses the
agent normally, and in that no-secret case configure_askpass also strips
SSHELF_VAULT_PASSPHRASE from the child env (ssh has no reason to inherit the vault master
passphrase). In the wired case the variable must stay: the helper runs as ssh’s child and
reads it to decrypt the vault (see docs/security.md).
ssh decides it needs a secret → because SSH_ASKPASS_REQUIRE=force, it executes the helper
as sshelf "<prompt text>" (the prompt is argv[1]; there is no --askpass flag).
The helper:
- Confirms it’s in askpass mode via
SSHELF_ASKPASS=1. - Reads
SSHELF_SECRET_KIND, which says whether the value behindSSHELF_HOST_IDis a loginpassword, a keypassphrase, or nothing at all (agent). A missing or unrecognised kind declines everything. - Inspects
argv[1]by OpenSSH prompt shape, and answers only the shape that matches its own kind:- Ends with
password:(classicuser@host's password:/ PAMPassword:) and the kind ispassword→ fetch the secret forSSHELF_HOST_IDfromsecrets(keyring or age vault), print it, exit0. - Looks exactly like OpenSSH’s local key prompt,
Enter passphrase for key '<path>':, the kind ispassphrase, and<path>is one of the paths inSSHELF_IDENTITY_FILES→ same, print the secret and exit0. - A secret-shaped prompt of the other kind, or a passphrase prompt naming a key this host does not use → exit non-zero to decline. It is never answered with the queued code either.
- Anything else (host-key
yes/no, OTP/verification codes, arbitrary server text) → the one-time code inSSHELF_2FA_CODEwhen one was queued for this connection, otherwise exit non-zero to decline. Never blindly print the secret.
- Ends with
Why shape alone is not enough
SSH_ASKPASS_REQUIRE=force makes ssh route every read_passphrase() call to the
helper, including the first-connect “Are you sure you want to continue connecting
(yes/no/fingerprint)?”. If the helper answered that with the stored secret, the connection
breaks.
The prompt text of a keyboard-interactive round is written by the server, and Password:
is a perfectly well-shaped prompt. So a host that rejects your key can ask for a password over
keyboard-interactive and, before 0.14.0, be handed the key’s passphrase. That is why the kind
is passed in and why a key host is also told which key files are in play: the only passphrase
prompt it will answer is OpenSSH’s own, naming a path it was given with -i. Defenses, in
order of how much they carry:
- The helper answers only prompts of its own kind, and a key host only for its own key files.
- Key hosts pass
-o PreferredAuthentications=publickey, so the server cannot offer password auth at all. A key host that also needs a verification code passespublickey,keyboard-interactive, since that is how the code arrives. - The helper matches the shape of real prompts rather than a bare substring, so “Type your password to continue:” is not treated as a secret prompt.
- sshelf passes
-o StrictHostKeyChecking=accept-new, so the host-key prompt never fires for new hosts (known hosts are still verified; changed keys still hard-fail). - The secret is host-scoped, limiting blast radius even if a prompt is mis-answered.
3a. The jump hop never sees the helper
ssh starts the ProxyJump hop as a child process, so it inherits SSH_ASKPASS and the rest
of the wiring. It does not forward the destination’s -o options to that hop: only -l,
-p, -J, -F and -v cross over. Nothing on the target’s command line constrains the hop,
so a hostile or compromised bastion could ask for a password and be handed the target’s stored
secret. What sshelf does instead, whenever the helper would be wired at all (a stored secret,
or a queued verification code):
-
One jump host, and the string is made only of
A-Za-z0-9._@:[]-: drop-Jand pass-o ProxyCommand=ssh -o BatchMode=yes -o PasswordAuthentication=no \ -o KbdInteractiveAuthentication=no [-l USER] [-p PORT] -W '[%h]:%p' JUMPUSER,PORTandJUMPcome from the storeduser@host:port.BatchMode=yeson its own disables password prompts; the two explicitnos are there so the rule does not rest on one reading of the man page. A hop reached this way can authenticate with an agent or an unencrypted key file and nothing else, which is what the FAQ always said jump hosts had to be. The allowlist is narrow becausesshruns aProxyCommandthrough your shell. -
Two or more hops, or one that does not parse or does not pass the allowlist:
-Jstays exactly as stored and nothing is wired.sshasks for the target’s secret on the terminal, and sshelf printsmulti-hop jump with a stored secret: ssh will ask for it on the terminalfirst so that is not a surprise. In tmux mode the connection falls back to the in-place handoff for the same reason: a new window has no terminal to ask on. -
Nothing stored and no code queued (agent hosts, key hosts with an unencrypted key):
-Jis untouched. There is no helper for a hop to inherit.
master_args (the transfer ControlMaster) and build_forward_command (port forwards) build
their argv through the same function, so they get the same treatment.
3b. A refused stored secret
ssh asks again after a refused password or passphrase, and a stateless helper would answer every
retry with the same wrong value. Every wired command therefore carries SSHELF_CONNECT_ID, a
fresh ULID, so the helper can tell a second prompt in one connect from the first prompt of the
next. Before answering a secret prompt, the helper creates askpass-<connect id> exclusively, at
mode 0600, in sshelf’s private runtime directory ($XDG_RUNTIME_DIR/sshelf, or
~/.local/share/sshelf/run, the parent of the transfer screen’s mux-<ulid> directories), and
writes the prompt it is answering into it.
- Created: the first secret prompt of this connect. Answer as usual.
- Already there, holding the same prompt: the earlier answer was refused, since that is the only
reason ssh asks again. Print
sshelf: the stored <password|passphrase> for <host id> was refused; replace it with sshelf set-password or ^e in the TUIon stderr and decline. A second line in the marker records that the line was printed, so a password prompt that comes back once per remaining attempt prints it once. - Already there, holding a different prompt (a second key file’s passphrase): answer as usual.
- No connect id (a tmux window, which never gets one), a malformed id, or no runtime directory: answer as usual. The marker fails open; the matching in §3 never does.
The helper never deletes the stored secret. configure_askpass removes askpass-* files older
than ten minutes before it wires a new command. The helper’s stderr is ssh’s stderr, so the line
shows up on the terminal of a real connect, and in the stderr the transfer screen and port
forwards capture, where ssh::classify_auth_error turns it into
could not authenticate: the stored password was refused; replace it with ^e.
Validated by the M0 spike (2026-06-05, macOS, OpenSSH 10.2)
Ran against a real password-auth sshd (lscr.io/linuxserver/openssh-server):
- Success path:
SSH_ASKPASS=helper SSH_ASKPASS_REQUIRE=force,PreferredAuthentications=password,StrictHostKeyChecking=accept-new→ logged in (exit 0). ConfirmsSSH_ASKPASSsatisfies interactivePasswordAuthenticationas well as key passphrases. The helper was called withargv[1] = "tester@127.0.0.1's password: ". - Host-key routing: with
StrictHostKeyChecking=askand a freshknown_hosts, ssh sent the helper the"...continue connecting (yes/no/[fingerprint])?"prompt; a naive helper that always returns the password caused an infinite loop on"Please type 'yes', 'no'...". That is the empirical proof that §3’s two rules are mandatory.
Linux verification is deferred to CI (M8); the mechanism is OpenSSH behavior and is expected to be identical.
4. Known v1 limitations
- Password-auth jump hosts are unsupported, and since 0.14.0 that is enforced rather than
only documented (§3a). The helper only has the target’s secret and can’t tell which hop is
prompting, so a hop is either constrained by an explicit
ProxyCommandor gets no helper at all. Jump hosts must use key/agent auth. - macOS unsigned builds: the re-exec’d askpass child reading Keychain may trigger an OS approval prompt every connect (Keychain ACLs are keyed to code signature). Ad-hoc sign for dev; document for users building from source.
- A key path longer than 100 bytes: OpenSSH truncates it in the passphrase prompt
(
Enter passphrase for key '%.100s':, checked against OpenSSH 10.3), the helper’s exact match declines, and a stored passphrase for that key is never supplied. - Windows: out of scope for v1 (
exec()replacement is Unix-only).
References
- OpenSSH
ssh(1),ssh_config(5)(ProxyJump,StrictHostKeyChecking). SSH_ASKPASS_REQUIRE, added in OpenSSH 8.4 (2020). This machine runs 10.2.std::os::unix::process::CommandExt::exec.
Architecture
sshelf is a single binary that runs in one of two modes:
- Interactive TUI (default, and subcommands like
import): the fuzzy launcher. - Askpass helper (
SSHELF_ASKPASS=1in the environment): a headless, non-interactive mode thatsshinvokes to obtain a stored password. Never run directly by the user.
High-level flow
┌─────────────────────────────────────────────┐
│ sshelf (TUI) │
│ │
hosts.toml ──▶│ store ──▶ model(Host) ──▶ search (fuzzy + │
state.json ──▶│ frecency) ──▶ ui (list/wizard) │
config.toml ─▶│ │
│ user presses Enter on a host ──┐ │
└────────────────────────────────────┼──────────┘
│
1. update frecency (use_count, last_used) & save
2. tear down TUI (raw mode off, leave alt screen, show cursor)
3. nothing stored? ask on the terminal, store it, prove it with
one `ssh ... exit`, keep it only if that worked (D-035)
4. set env: SSH_ASKPASS=self, SSH_ASKPASS_REQUIRE=force,
SSHELF_ASKPASS=1, SSHELF_HOST_ID=<id>,
SSHELF_SECRET_KIND=<kind> [, SSHELF_IDENTITY_FILES=...],
SSHELF_CONNECT_ID=<fresh ulid>
5. exec("ssh", argv...) ← process is REPLACED; sshelf is gone
│
▼
┌─────────────────────────────────────────────┐
│ ssh │
│ needs a password? ─▶ runs SSH_ASKPASS: │
│ `sshelf "<prompt>"` (SSHELF_ASKPASS=1) │
│ │ │
│ ▼ │
│ askpass mode: inspect argv[1]; │
│ own kind's prompt ─▶ secrets.get(host_id) │
│ (keyring → age vault) ─▶ print, exit 0 │
│ else ─▶ exit non-zero (decline) │
└─────────────────────────────────────────────┘
│
interactive ssh session
│
session ends → back at shell
Why this shape
-
exec()(process replacement), not spawn+wait. The user chose exit-to-shell: when the SSH session ends, they’re back at their normal prompt.exec()givessshthe real TTY with zero indirection, the cleanest possible handoff. Consequence: no code runs afterexec(), so anything that must persist (frecency) happens before it. -
One exception: tmux mode. With
tmux = "window"/"pane"and$TMUXset, connect spawnstmux new-window/split-windowinstead and sshelf stays up, so several hosts can be opened in a row. Frecency is persisted before the spawn for the same reason. Connections whose authentication would have to cross tmux’s argv (a 2FA code, a vault passphrase) fall back to theexec()path; seesecurity.mdand D-025. -
Password auto-supply via
SSH_ASKPASS, notsshpass.sshnever accepts a password on the command line;sshpasswould expose it inps/argv and is an extra dependency.SSH_ASKPASS(OpenSSH 8.4+) letssshcall a helper program for the password. sshelf points it at its own binary. WithSSH_ASKPASS_REQUIRE=force, ssh uses the helper even though a TTY is present. Seessh-command.mdfor the full mechanism and its sharp edges (the helper must inspect the prompt; host-key prompts must be neutralized). -
Two-tier secrets. OS keyring (macOS Keychain / Linux Secret Service) is the primary store. Headless/minimal Linux often has no Secret Service daemon, so an
age-encrypted vault (unlocked by a master passphrase, cached in-memory per session) is the fallback. Seesecurity.md. -
Own database, never
~/.ssh/config. Hosts live inhosts.toml(human-readable, atomic writes). Importers (~/.ssh/config, a Tailscale tailnet) are read-only towards their source and add-only towards the database. Seedata-model.md. -
Synchronous event loop, component pattern. No background work needs multiplexing (the one long-running thing, the SSH session, happens after the TUI is gone). A simple
crossterm::event::read()loop with a component-per-screen structure (HostList, Wizard, Help, Confirm) keeps it small and tokio-free.
Component map
See structure.md for the file-by-file breakdown. At runtime:
Appowns top-level state (current screen, query, selection, loaded hosts + state) and routes events to the active component.searchturns(hosts, state, query)into a ranked, highlight-annotated view.sshis the only place that builds argv and performs the teardown +exec().secretsis the only place that talks to the keyring/vault; both the TUI (to store on add/edit) and the askpass mode (to retrieve) go through it.
Failure handling
- If
exec()returns, it failed (e.g.sshnot found) → restore the TUI and surface the error. - If the askpass helper can’t get the secret → exit non-zero so
sshfalls back to prompting the user, rather than hanging or sending a wrong answer. - A panic mid-TUI restores the terminal via a guard + panic hook before unwinding.
Project structure
Keep this in sync with the actual tree (the docs-in-sync rule).
All modules present:
main,app,askpass,config,doctor,export,forwards,import,model,paths,search,secrets,ssh,state,store,tailscale,transfer/{mod,worker,pane,screen,e2e},testsupport(test-only),vault,ui/{mod,list,help,widgets,wizard,browse,settings,sites,transfer,forward_popup,forwards,two_factor}. (error.rswas removed; the codebase usesanyhowthroughout.)
Repository
ssh-tui/ (crate/binary name: `sshelf`)
├── Cargo.toml
├── CONTRIBUTING.md contributor guide + docs-in-sync rule
├── README.md (M8) user-facing intro + positioning
├── SECURITY.md (M8) threat model for OSS users (mirrors docs/security.md)
├── LICENSE-MIT (M1)
├── LICENSE-APACHE (M1)
├── docs/ living documentation (this directory)
└── src/ see below
src/ modules
| File | Responsibility |
|---|---|
main.rs | Entry/dispatch. If SSHELF_ASKPASS is set → askpass mode (read argv[1]). Else clap parses: default TUI, or subcommands (import, list, add, add --from-ssh). Also the connect tail both exec paths share: the first-connect prompt and store, the 2FA prompt, the echo-off terminal reader, and the handoff. |
app.rs | App state + synchronous event loop + screen routing (component orchestration). |
model.rs | Host + Site structs (+ AuthMethod); Host::with_site_defaults/find_site (site inheritance); serde derives. |
store.rs | Load/save hosts.toml with atomic write (exclusive temp + rename); create_exclusive, the shared “create this file and fail if the name is taken” helper the forward logs use too. |
state.rs | Frecency state (use_count, last_used) load/save (state.json); score computation. |
forwards.rs | Background port-forwards: the ForwardSpec/ForwardEntry model, the -L/-R/-D argv builder, spawn (detached ssh -N + readiness/error mapping), PID liveness/kill via ps/kill, reconcile, and forwards.json load/save. |
secrets.rs | SecretStore trait → keyring backend + age-vault fallback; zeroize on secrets. Also the read-only introspection doctor needs: which backend is active, a write-read-delete probe, and (vault only) the stored ids. |
ssh.rs | Build ssh argv from a Host; the JumpPlan that decides whether a jump chain is -J, an explicit ProxyCommand, or a terminal prompt; terminal teardown + exec() handoff; askpass env wiring (including the per-command SSHELF_CONNECT_ID); the first-connect probe and verify commands; the tmux new-window -d/split-window -d spawn and the rules for what may cross that boundary. |
askpass.rs | Headless askpass entry: inspect argv[1] and answer only the prompt shape matching SSHELF_SECRET_KIND (a login password, or this host’s own key passphrase per SSHELF_IDENTITY_FILES), or a queued 2FA code (SSHELF_2FA_CODE) for anything that is not secret-shaped; else decline. Also the askpass-<connect id> marker that turns a repeated secret prompt into one “was refused” line and a decline, and the sweep of stale markers. |
sshcmd.rs | sshelf add --from-ssh: read one ssh command line with OpenSSH’s option grammar and map it onto a Host (the fields it models, the rest as extra_args, and a note for each option it drops). Pure; the working directory a relative -i resolves against is its only outside input. |
first_connect.rs | Saving the secret on first connect: the trigger as a pure function over auth, stored secret, encrypted key, jump plan and tmux; ssh-keygen -y for encrypted keys; the bounded probe and verify runs and their exit-status rules. The prompt and the exec stay in main.rs. |
search.rs | Fuzzy filter (nucleo-matcher) + frecency ranking + per-row match indices for highlight. |
import.rs | ssh2-config parse of ~/.ssh/config → Host mapping; warn on unsupported Match/Include. Also the shape (ImportResult) and add-only plumbing (new_hosts, missing_sites) every importer shares. |
tailscale.rs | sshelf import --tailscale: locate the user’s tailscale binary, run status --json, map eligible peers → hosts (MagicDNS name/FQDN, tailnet → site, ACL tags). Pure parser over &str; the only process spawn is isolated in one function. |
doctor.rs | sshelf doctor: one pure function per check (OpenSSH version, hosts-file parse + duplicates, secret backend, dangling sites, orphaned secrets, ssh-agent, export freshness, config-directory permissions) over inputs the caller gathers, plus the report’s rendering and exit-code rule. |
export.rs | Render the database as an ssh_config Include fragment (site defaults resolved, -o extras translated); write to ssh_config in the config dir; auto-refresh on hosts saves once the file exists. |
paths.rs | etcetera path resolution (config/data dirs); file paths; ensure_private_dir, which creates a directory 0700 and re-applies that mode only to one sshelf owns; runtime_dir, the private directory the transfer screen’s mux sessions and the askpass markers share. |
display.rs | One sanitizer for plain CLI output: control characters, C1 codes and bidirectional overrides become U+FFFD, and the same set is what the add form and the importers refuse. |
config.rs | Preferences: decay_rate, default_sort, accent color, tmux mode; writes a commented default on first run. |
transfer/mod.rs | File-transfer core: ssh-ControlMaster + sftp argv builders, the worker↔UI message protocol (WorkerCmd/WorkerEvent), and progress math. |
transfer/worker.rs | Background worker thread: owns the ControlMaster and the 0700 session directory its socket lives in (open/readiness/teardown), lists remote dirs (sftp ls -lan) and creates them (sftp mkdir) under a deadline and an output cap, runs sftp get/put transfers with progress + cancel, and installs a single file sent either way with a no-replace link (link() locally, sftp ln on the server). |
transfer/pane.rs | One side’s browsing state (fuzzy filter + selection + nav + positional marks, reusing search); read_local_dir for the local side; RemoteEntry→PaneEntry. |
transfer/screen.rs | The dual-pane TransferScreen: two panes over one session, key handling, the send queue (marks → one transfer at a time, skips vs failures), the new-directory input, draining worker events. |
ui/list.rs | Host list rendering + match highlighting + selection. |
ui/transfer.rs | Renders the transfer screen (two panes + mark glyphs + the new-directory input + progress/status + hint bar) from a borrowed view. |
ui/wizard.rs | Auth-aware add/edit form: fields, validation, key picker, opens the file browser. |
ui/browse.rs | File-browser modal (fuzzy-filtered) for picking a key file anywhere on disk. |
ui/settings.rs | Settings screen (F2): config-file display, editable hosts-file location, tmux-mode toggle. |
ui/sites.rs | Sites manager (F3): list + add/edit/delete sites and their optional defaults; emits renames for the app to cascade. |
ui/forward_popup.rs | New-port-forward popup (Ctrl-f): kind chooser (Local/Remote/Dynamic) + ports/host fields + validation; emits a ForwardSpec for the app to spawn. |
ui/forwards.rs | Port-forwards manager (F4): lists all active forwards from a live snapshot; emits a kill request for the app to act on. |
ui/two_factor.rs | 2FA code popup shown before connecting to a requires_2fa host; emits the entered code for the app to queue + supply via askpass. |
ui/help.rs | Help overlay. |
ui/widgets.rs | Shared widgets: single-line text input (hand-rolled), keybind hint bar, confirm modal. |
Data flow
paths ──▶ store ──▶ model(Host[]) ┐
paths ──▶ state ──▶ frecency ├─▶ search ──▶ ui ──▶ (Enter) ──▶ ssh ──▶ exec
┘ │
secrets ◀── ui (store on add/edit) askpass ◀── ssh (via SSH_ASKPASS)
└────────────────────────────────────────────────────▶ secrets (retrieve)
Conventions
- One responsibility per module;
ssh.rsis the only place that callsexec();secrets.rsis the only place that touches the keyring/vault. - No
unwrap()/expect()on fallible I/O in non-test code; return errors and surface them in the UI. - Every new/moved module updates this file.
Data model & on-disk layout
File locations
Paths resolve via the etcetera base strategy (XDG everywhere: ~/.config/sshelf on
both macOS and Linux, honoring XDG_CONFIG_HOME/XDG_DATA_HOME when set). This keeps
config hand-editable instead of buried in macOS ~/Library.
| File | Location (default) | Owner | Purpose |
|---|---|---|---|
hosts.toml | ~/.config/sshelf/hosts.toml | user | The host database. Human-editable. |
config.toml | ~/.config/sshelf/config.toml | user | Preferences (theme, decay_rate, sort, keybinds). |
state.json | ~/.local/share/sshelf/state.json | app | Frecency counters, keyed by host id. Churns; not for hand-editing. |
forwards.json | ~/.local/share/sshelf/forwards.json | app | Ledger of active background port-forwards (PIDs). Reconciled against the OS on launch. Mode 0600. |
ssh_config | ~/.config/sshelf/ssh_config | app | Exported ssh_config Include fragment (sshelf export), derived from hosts.toml and refreshed on every hosts save once present. Mode 0600. |
vault.age | ~/.local/share/sshelf/vault.age | app | Fallback encrypted secret store (only when no OS keyring). Mode 0600. |
| forward logs | ~/.local/share/sshelf/logs/fwd-<id>.log | app | One per running background forward: that ssh -N’s stderr. Removed when the forward stops or is reaped. Mode 0600, in a 0700 directory. |
| mux sockets | $XDG_RUNTIME_DIR/sshelf/mux-<ulid>/m.sock | app | The transfer screen’s ControlMaster socket, in a directory created per session at mode 0700 and removed when the screen closes. Falls back to ~/.local/share/sshelf/run/ when XDG_RUNTIME_DIR is unset. |
| askpass markers | $XDG_RUNTIME_DIR/sshelf/askpass-<ulid> | app | One per wired connect whose helper answered a secret prompt: the prompt text, so a repeat of it can be recognised as a refusal (ssh-command.md). Created exclusively at mode 0600; nothing secret in it. Removed once older than ten minutes, by the next wired connect. Same fallback directory as the mux sockets. |
Directories sshelf creates are created 0700, and only the last component: a missing ancestor
of a custom --config path is created the way mkdir -p would. sshelf never changes the mode
of a directory it did not create, so pointing --config at a file in a shared directory leaves
that directory exactly as it was. Files sshelf creates are created with their final mode
already set rather than chmodded afterwards, and always exclusively, so an existing file or a
symlink at the name fails the write instead of being followed. Secrets are never written to
hosts.toml.
Host / Site schema (hosts.toml)
format_version = 1 # top-level scalar; for future migrations
[[site]] # optional; sites are listed before hosts (scalars-before-AoT)
name = "prod-dc" # the name hosts reference (see host.site)
user = "deploy" # optional default login for member hosts
port = 22 # optional default port
jump_hosts = ["bastion.prod"] # optional default ProxyJump (the site's bastion)
identity_files = ["~/.ssh/prod"] # optional default key(s) (applied to key-auth members)
[[host]]
id = "01J..." # any string unique in the file (sshelf writes a ULID); keys secrets & frecency
name = "prod-db" # display alias (what you search/see)
hostname = "10.25.25.25" # IP or DNS name (required)
user = "mike" # optional; default = $USER at connect time
port = 22 # optional; default 22
auth = "key" # "key" | "password" | "agent"
identity_files = ["~/.ssh/infra-key"] # for auth="key"; repeatable (-i per entry)
jump_hosts = ["bastion.example.com"] # ProxyJump chain; key/agent auth only in v1
tags = ["prod", "db"] # many-valued, free-form; for filtering/grouping
site = "prod-dc" # optional; one site (by name); groups + inherits its defaults
requires_2fa = true # optional (default false); connect prompts for a verification code
extra_args = "-o ServerAliveInterval=30" # raw, shlex-split, appended verbatim
# NOTE: no password field, ever. auth="password" means "look up the secret by id".
Notes:
- Optional fields use
Option<T>in Rust with#[serde(skip_serializing_if = "Option::is_none")]so the TOML stays clean; new fields use#[serde(default)]for backward compatibility. identity_files/jump_hosts/tagsareVec<String>(empty = absent).format_versionlets sshelf migrate the schema later without breaking older files. Adding[[site]]andhost.siteneeded no bump: old files load withsites = []/site = None.requires_2famarks a host whose login needs an interactive verification code; connect collects it and passes it tosshvia the transientSSHELF_2FA_CODEenv var (never stored on disk). Seedecisions.mdD-022.
Sites vs tags, and inheritance
A Site is one per host and may carry optional shared SSH defaults; tags are
many-valued free-form labels. At connect time a host is resolved into an effective host
(Host::with_site_defaults): for user, port, jump_hosts, identity_files, the site’s
value fills in only where the host leaves that field unset, so the host always wins. Auth is
not inheritable. A host that names an undefined site still groups under that name but
inherits nothing (graceful degradation). Renaming a site (F3 manager) cascades to member hosts;
deleting one clears members’ site. See decisions.md D-020.
Frecency state (state.json)
{
"01J...": { "use_count": 12, "last_used": "2026-06-05T09:30:00Z" }
}
- Keyed by host
id(so renaming a host inhosts.tomlkeeps its history). - Updated before
exec()on connect:use_count += 1,last_used = now. - Kept separate from
hosts.tomlso the user-owned host file stays stable and diff-friendly. - Score:
use_count * exp(-decay_rate * days_since_last_used)(decay_ratedefault0.2). Seeux.mdfor how it combines with fuzzy ranking.
Port-forward ledger (forwards.json)
[
{
"id": "01J...", // ULID; also names the forward's stderr log
"host_id": "01J...", // originating host id
"host_name": "prod-db", // snapshot for display
"kind": "local", // "local" | "remote" | "dynamic"
"spec": { "listen_port": 8080, "target_host": "db", "target_port": 3306 },
"display": "L 127.0.0.1:8080 → db:3306",
"pid": 41234, // the detached `ssh -N` process
"started_at": 1718900000
}
]
- App-owned; written atomically (
0600). The runningsshprocesses are authoritative; this file is only a remembered list of PIDs, reconciled against the OS (ps) on startup, on opening the manager, and each tick while it’s open. A forward leaves the ledger the moment its process is gone, however it ended. Seedecisions.mdD-021. specomits empty fields (binddefaults to127.0.0.1,target_hosttolocalhost); Dynamic forwards carry onlylisten_port.
Secrets
Stored in the OS keyring (service sshelf, account = host id) or, as a fallback on
headless systems, in vault.age. Either way the key is the host id. Full model and threat
analysis in security.md.
Atomic writes
All persistent writes use temp-file + rename() (atomic on Unix) so a crash mid-write never
corrupts hosts.toml / config.toml / state.json. Single-process tool → no file locking needed.
UI design notes
How the interface is designed and why. What each screen does, and every keybinding, is documented in the user Guide (Searching & connecting, Adding & editing hosts, Transferring files, Port forwarding, Sites & tags); this page holds the design rationale and rendering details behind those screens.
Visual model
atuin.sh: slim chrome, an inline filter-as-you-type list, and a contextual keybind hint bar at the bottom. The search box is always active (single-mode, no insert/normal split), so plain letters filter the list; actions therefore use Ctrl or function keys, which can’t be typed into the query.
Main screen layout: Length(3) search · Min(0) list · Length(1) hint bar. Each row shows
name · user@host[:port] · [tags], plus a dim ·site· column while filtering. The
matched/total count lives in the search-box title so it’s never truncated by a narrow
terminal.
Sorting / ranking
- No query (idle): frecency descending,
score = use_count * exp(-decay_rate * days_since_last_used),decay_ratedefault0.2(default_sort = "name"opts out). The idle view groups by site. - Typing: fuzzy-filter via
nucleo-matcher; sort by match score with frecency breaking ties. Matched characters are highlighted (bold/accent) using the matcher’s match indices, rendered withunicode-widthso wide/combining characters don’t misalign. - Fuzzy only for now (prefix/substring modes can come later).
The add/edit form
A single-screen, auth-aware field form rather than a paged wizard. It’s simpler to navigate
and edit, “guided” by dim placeholders (required · / optional ·) and inline validation
with focus jumping to the offending field. Fields specific to an auth method only render for
that method. The Key field is a picker (single key) backed by a fuzzy file-browser modal;
key discovery matches keypairs (.pub sibling) and standalone private keys by their
PRIVATE KEY header so .pem files show up. A host configured with multiple identity files
keeps them on edit; entering several is done by editing hosts.toml.
Modality & precedence
Full-screen modes (transfer, sites, forwards manager) and popups (forward, 2FA, confirms)
route keys before the list screen (first match wins) and render in the same precedence
order, so input routing and drawing can’t disagree. Destructive actions (delete a host, stop
a forward) confirm with y; any other key cancels. Help (F1) is an overlay listing every
key; any key closes it.
Theming
atuin-inspired defaults: dim chrome and a single accent color (config key accent) for the
selection + match highlights. Terminal resize is handled by ratatui’s layout pass, with no manual
recompute.
Decision log
ADR-style. Newest on top. Each entry: the decision, why, and what we rejected. Add an entry whenever you make a non-trivial design choice.
D-035 · A connect with nothing stored asks once, proves the answer, then keeps it
Secrets never live in hosts.toml. That is the point of the file, and it also means a
hand-written, generated or copied hosts.toml has hosts with nothing stored, which prompted on
every connect until someone ran set-password. A 2FA password host couldn’t be used at all
without it, because a code-only helper has no terminal fallback for the password.
A connect to a password host with nothing stored, or to a key host whose key needs a passphrase
(ssh-keygen -y -P '' fails with a passphrase error), now asks on the restored terminal, after
the frecency save and before the 2FA prompt. The answer is stored first, since the askpass helper
reads the store and nothing else can carry it to ssh without argv. One throwaway ssh ... exit,
wired exactly like the connect, then proves it. OpenSSH defines the result: 255 is ssh’s own
failure, so 255 with Permission denied is a refusal and any other status is the remote exit.
A refused secret, or one that could not be checked, is deleted again and sshelf exits 1. An
encrypted-key host is probed once with BatchMode=yes before anything is asked, so a key already
in the agent is never asked about, and a host that can’t be reached connects normally and shows
ssh’s own error.
Enter skips, and nothing is remembered: asking on every connect until something is stored is
the feature. 2FA hosts are stored without the check, which would burn the code, and the line says
so. In tmux mode a connect that would ask falls back in place, deciding only the part that needs
no network, since the probe must not run inside the event loop. In the TUI, a 2FA host that would
ask skips its code popup, so the secret and then the code are both asked on the terminal: the
code is the one that goes stale.
The second half is the wrong stored secret, which was answered again on every retry. Each wired
command now carries SSHELF_CONNECT_ID, a fresh ULID, and the helper creates askpass-<id>
exclusively in the private runtime directory (D-030) the first time it answers a secret prompt.
The same prompt again means the answer was refused: the helper prints one line naming
set-password and ^e, and declines. An empty marker would be enough for a password host; the
marker holds the prompt so that a host with two encrypted key files isn’t told its passphrase was
refused when ssh moves on to the second key. A missing id or directory fails open on the
marker, never on the secret. The helper’s stderr is ssh’s stderr, and the e2e suite checks the
line reaches it on a real connect, so there was no need to fall back to
NumberOfPasswordPrompts=1. classify_auth_error also looks for the line, so the transfer screen
and port forwards name the stored secret.
Rejected: an askpass “learn” mode (it gives the helper write access to the store in the one place
the prompt text is server-controlled); storing without a check for non-2FA hosts (a typo would be
supplied on every later connect); a config switch to turn the prompt off (Enter skips); and
deleting a refused secret from the helper. A server can ask twice for its own reasons, for example
offering keyboard-interactive and password where the first fails on the server side, and the
helper would then destroy a correct secret. The user replaces it on the strength of the message.
D-034 · add --from-ssh reads the ssh grammar, maps what it can, keeps the rest verbatim
Most hosts already have a working ssh line somewhere: shell history, a runbook, a message from a
colleague. sshelf add --from-ssh takes that line, as an argument or on stdin, and reads it with
OpenSSH’s own option rules: booleans combine, values attach or take the next word, -- ends
options. What the host model has a field for is mapped ([user@]host or an ssh:// URL, -l,
-p, -i, -J, and password auth when -o PasswordAuthentication=yes or
PreferredAuthentications=password says so), everything else lands in extra_args in its
original order, re-quoted so the connect-time shlex::split gives the same words back. The result
opens the add form filled in, or with --quiet is saved like a flag-built add.
The parser follows ssh itself rather than the synopsis in one place: options after the
destination are read too, because ssh re-parses them (ssh host -p 2222 works), and the first
plain word after the destination starts a remote command. -l and -p win over the
destination’s user and port. A relative -i is made absolute when the host is added, since a
saved host is connected from any directory. Nothing is resolved: an alias stays an alias, and -F
rides along in extra_args.
A trailing remote command is an error rather than something to drop, since a saved host has no
command. -v, -q, -G, -V, -Q, -O, -S, -E, -M, -N, -f, -n, -g, -s and
-o StrictHostKeyChecking are dropped with a note each: they would change what the saved host does
on every connect, or, for the host-key option, record something sshelf overrides (it passes
accept-new ahead of the extras, and ssh keeps the first value).
The form is fed by a pipe as often as by an argument. crossterm 0.29 reads keys through tty_fd,
which opens /dev/tty when stdin is not a terminal, but on macOS that stops at Failed to initialize input reader: its mio event source registers the descriptor with kqueue, and kqueue
refuses the /dev/tty alias with EINVAL while accepting the terminal’s own device
(/dev/ttys004, checked both ways). So the prefilled path reads everything it needs from the pipe
first, then opens the device ttyname reports for stdout or stderr (/dev/tty only as a last
resort) and dup2s it onto fd 0 before the TUI starts. With no terminal at all, the host is added
the --quiet way, with a note saying so.
Rejected: a shell function that intercepts ssh (sshelf never wraps ssh); reading shell history
(three formats, and no consent); silently ignoring a trailing remote command; keeping -v, -N,
-f or StrictHostKeyChecking in extra_args. A literal ssh ... | sshelf can’t exist, because
the pipe runs ssh; "$(fc -ln -1)" is the way to hand over the command just typed.
D-033 · An upload installs itself with a remote ln, so only folders rest on the listing
Downloads have never rested on the destination listing: a single file lands on a
.sshelf-part-… temporary and is installed with link(), which fails if the name is taken.
Uploads had no such step. They were checked against the listing the remote pane happened to be
showing and then sent with put, which creates and truncates, so a file that appeared on the
server after the check was overwritten. Reported by a reader who noticed the asymmetry in the
comment in transfer/screen.rs and asked what covered the gap. Nothing did.
The window was also wider than docs/transfer.md claimed. The refresh that goes out before each
upload lands too late to inform that upload’s own check, so the first item of a send was tested
against whatever the pane last showed, and a later item against a listing taken before the
previous transfer began, which on a big file is the length of that transfer.
An upload now mirrors a download exactly: put writes a .sshelf-part-… temporary in the
destination directory, and sftp’s own ln (a hard link, via hardlink@openssh.com) installs
it under the real name. The server refuses the link when the name is taken, symlinks included
and never followed, and the entry is reported as the same skip the pre-flight check raises.
Protocol 3 gives no code for “name taken” (the server’s EEXIST arrives as a bare Failure),
so a refused link is followed by an ls of the destination to find out which refusal it was.
That listing only explains the failure; the link had already decided it.
Where the link cannot be made at all, because the server lacks the extension or the remote
filesystem has no hard links, the temporary is renamed onto the name that the ls just showed
free. That is the same check-then-move the exFAT case takes locally, weaker than the link but
narrower than the window put had. Rejected: probing the server’s extensions up front (a round
trip per session for something the failure path answers for free), and rename -l to force the
legacy no-clobber rename (not in every OpenSSH, and posix-rename overwrites where it is used).
Consequence: a folder is now the only send with nothing but the listing behind it, in either direction, so the refusal to send into a listing cut short at 50,000 entries applies to folders alone. Single files go into such a directory without complaint.
D-032 · A background ssh fails rather than prompts
The transfer ControlMaster and a port forward both start while sshelf still owns the terminal in
raw mode. Neither had any way to answer a prompt, and neither was stopped from being asked one.
OpenSSH reads a passphrase from /dev/tty, not from stdin, so the worker closing the child’s
stdin did nothing: on a key host with no stored passphrase and nothing in the agent, ssh printed
Enter passphrase for key '...' straight over the TUI’s hint line while every keystroke went to
sshelf’s event loop. The prompt could not be answered, the master sat there until the handshake
timed out thirty seconds later, and the screen then blamed a password that had never been asked
for (issue #18).
When the askpass helper is wired there is an answer for every prompt and nothing changes. When
it is not, both argv builders now emit -o BatchMode=yes ahead of build_args, so it wins over
anything in the host’s extra_args: ssh keeps the first value it is given for an option. ssh
gives up in about a second instead, and because that failure is a real exit it carries stderr
worth reading. ssh::classify_auth_error turns the resulting bare Permission denied (publickey) into the two things the user can actually do: load the key with ssh-add, or store
its passphrase on the host with ^e. The handshake’s timeout message stops mentioning passwords
at all, because a credential problem can no longer reach it.
The cost is that a host needing an interactive answer sshelf does not hold (a 2FA host, or a key whose passphrase is not stored) cannot open a transfer screen or a forward at all. It could not before either; it hung and then lied about why. Prompting inside the TUI before the master starts is the better answer and is worth doing, but it is a feature, not this fix.
Rejected: wiring the helper unconditionally so it declines. It works, but it hands the vault passphrase environment to a child that has no secret to look up, which D-029 deliberately scrubs. Rejected: leaving the timeout as the safety net, which is the behaviour being fixed.
D-031 · Remote listings are formatted by the client, with numeric ids
The remote pane parsed sftp’s ls -la by whitespace column, taking the size from field five
and the name from field nine onward. Under a plain -l, sftp prints the server’s longname
string verbatim, and that string carries the owner and group as names. On a host whose accounts
come from AD/LDAP the group is spelled domain users, the space made it two columns, and every
field after it shifted one to the right: the size was read off the month, and each filename
arrived with the tail of the timestamp glued to the front, which broke navigation outright
(issue #20).
The listing now asks for ls -lan. -n makes the client format the line itself from the file
attributes rather than echoing the server’s, and the ids come out numeric, so the column count
can no longer depend on how a remote host happens to spell its groups. -a still carries
dotfiles, for the reason D-028 gives. The parser also stopped treating a size it cannot parse as
zero: a non-numeric size means the columns are not where it thinks they are and every later
field is suspect, so it skips the line rather than handing the pane an entry it would try to
walk into.
Rejected: parsing the owner and group by counting backwards from the timestamp. A filename may
contain spaces too, so neither end is a fixed anchor, and the result is a parser that guesses.
Rejected: gating -n on an OpenSSH version. It has been in sftp’s ls far longer than the
8.4 floor doctor already checks for.
D-030 · Private runtime files: exclusive creation, a 0700 session directory, and no borrowed chmods
Four small file-handling habits added up to more exposure than any of them looked like on its
own. The transfer screen’s ControlMaster socket was /tmp/sshelf-mux-<pid>-<seq>.sock, which
another local account can pre-create (that alone stalls the handshake for its full 30 seconds)
or, on an OpenSSH build that does not check socket ownership, sit on and answer as the mux, so
the sftp ride commands go somewhere else entirely. atomic_write derived its temp name from
the process id and opened it with File::create, which follows a symlink. Port-forward stderr
logs and the optional transfer log went to predictable /tmp names at whatever the umask
happened to be, and they carry hostnames, paths, commands, ssh errors and every extra_args
value. And ensure_dirs chmodded the config directory to 0700 on every run, including a
directory the user had chosen with --config and never asked sshelf to make private.
The rules now:
Sockets live in a directory nobody else can write. $XDG_RUNTIME_DIR/sshelf/ when that
variable is set and names a real directory, otherwise run/ under the data dir. Inside it, a
fresh mux-<ulid> created with mkdir(0700) and no create_dir_all: a name that already exists
is a name somebody else got to first, so sshelf takes another one rather than moving in. The
socket is m.sock inside that, and both go away on teardown. An AF_UNIX path over 100 bytes is
an error naming the path, not a silent fall back to /tmp, which would give up exactly the
protection the directory exists for.
Files sshelf creates are created exclusively, with the mode at open(2). Temp names carry a
ULID rather than the pid, and create_new refuses an existing file or symlink instead of
following it. The mode is passed to the open and then applied exactly through the handle already
held, so there is no second path lookup to race and no window where the umask has decided
something wider. Because a unique name means a crashed write is never reclaimed by the next one,
each write also sweeps sibling temp files older than an hour.
Logs live in the data dir. Forward stderr goes to <data_dir>/logs/fwd-<id>.log, 0600
inside a 0700 directory. The user-selected transfer log is opened O_NOFOLLOW and 0600, and a
symlink at that path gets one line on stderr and no log rather than a redirected one.
sshelf never changes the mode of a directory it did not create, and only ever the last
component: --config /srv/team/sshelf/config.toml creates sshelf at 0700 and leaves
/srv/team to mkdir -p. The default config and data directories are still held at 0700 every
run, because those are sshelf’s own. sshelf doctor reports a config directory that is
group-writable, world-writable, or not owned by you, which is the read-only half D-027 allows.
Rejected: keeping a /tmp fallback for a socket path that will not fit, since a fallback that
undoes the fix is worse than an error that names the problem. Rejected: refusing a symlinked
config or data directory. Dotfile managers routinely make ~/.config a symlink into a checkout,
refusing to write through one would break an entirely ordinary setup, and anyone who can repoint
that symlink can already read what it leads to. Rejected: chmodding an existing custom config
parent to 0700 “to be safe”, which is how a shared directory quietly loses access for everyone
else on the box.
D-029 · The askpass helper is scoped to the secret it holds, and a jump hop never gets it
The helper answered any prompt ending in password: and any prompt containing passphrase for
with the one stored value for the host, whichever kind that value happened to be. Shape matching
was the whole defence, and Password: is a valid shape. On a keyboard-interactive round the
prompt text is written by the server, so a host that rejects your key can ask for a password
and be handed the key’s passphrase. The same wiring is inherited by the process ssh starts for
a ProxyJump hop, and OpenSSH forwards only -l, -p, -J, -F and -v from the destination
to that hop, so nothing on the target’s command line constrained it either.
Three changes, together:
The secret has a kind. configure_askpass sets SSHELF_SECRET_KIND to password,
passphrase or agent from the host’s auth method, and for key hosts also
SSHELF_IDENTITY_FILES (the -i paths, ~ already expanded). A password host answers only a
login-password prompt; a key host answers only OpenSSH’s own Enter passphrase for key '<path>':
and only when <path> is one of those files. A secret-shaped prompt of the wrong kind is
declined and is never answered with a queued verification code instead. A missing or unreadable
kind declines everything. The third value, agent, is there so a key-or-agent host with a
verification code and no stored secret still answers the code prompt while refusing both secret
shapes.
Key hosts constrain the server. They connect with -o PreferredAuthentications=publickey,
or publickey,keyboard-interactive when the host needs a verification code, so a server cannot
offer password auth at all. This is the one user-visible behaviour change: a key host that used
to quietly fall back to a password prompt on the server now fails with the server’s
“Permission denied (publickey)”. Add the password as a second host, or switch that host’s auth
to password.
A jump hop is constrained or gets nothing. Whenever the helper would be wired, one jump host
is expressed as an explicit ProxyCommand running the hop with BatchMode=yes,
PasswordAuthentication=no and KbdInteractiveAuthentication=no, which leaves it an agent or an
unencrypted key file and nothing else. Two or more hops cannot be constrained individually, so
nothing is wired: ssh asks for the target’s secret on the terminal, and sshelf says so first.
A jump string outside A-Za-z0-9._@:[]- takes the same path, because ssh runs a
ProxyCommand through the user’s shell and an allowlist is cheaper to be sure about than
quoting. Ctrl-y and sshelf print-command resolve the stored secret first so the copied
command matches the real one; sshelf list --json does not, because a listing has no business
reading the keyring once per host, and its command field is unchanged by this release.
Rejected: having the helper inspect its parent process’s argv to work out which hop is asking,
which is fragile across platforms and depends on how ssh happens to spawn the hop. Rejected:
writing a temporary ssh -F config for the hop, which would shadow the user’s own
~/.ssh/config aliases, and the never-touch-SSH-config promise says no generated config files.
Rejected: keeping shape matching as the only defence, since the shapes are exactly what a server
can imitate.
D-028 · Transfer panes list hidden files on both sides, with no toggle
The remote pane listed through sftp’s own ls -l, which drops dot-entries, while the local
pane read the directory itself and kept them. A host’s .config was therefore invisible, with
no way to walk into it (#15). The listing now uses ls -la and the two sides agree.
Rejected: a show/hide-hidden toggle. It would need a key (and Ctrl-h is Backspace in most
terminals), a line in the help overlay, a docs section, and a decision about whether the local
pane follows the same switch. The narrowing case is already covered by the filter that is
there: type a . and the pane keeps the names that have one. A real toggle can be its own
small change if people ask for it.
. and .. are still dropped in parse_ls_line. That check was written defensively before
those entries could arrive; with -a they really do. Sorting is untouched (directories first,
then case-insensitive by name), so dotfiles land wherever that comparator puts them.
D-027 · doctor is local, read-only, and exit-code shaped, with no --json yet
sshelf doctor exists because every support question so far has had one of six causes, and
none of them is visible from inside sshelf: an OpenSSH older than 8.4, a secret backend that
isn’t there, a hand-edited hosts.toml, a site that was renamed away, an agent that isn’t
running, an export fragment that drifted. One command that names all six, and the fix for each,
is worth more than six FAQ entries, so the FAQ now points at it instead.
Local only. Every check reads the filesystem, the environment, the secret backend, or
ssh -V. No pings, no test connections, no version lookups. sshelf’s no-network posture (D-024,
security.md) is a promise, not a default, so it isn’t relaxed for diagnostics: doctor
reports whether sshelf is set up, never whether a host is up. Rejected: a connectivity probe
per host, which would be the most-requested feature of the command and the first thing to turn it
into a monitoring tool, which is explicitly out of scope.
Read-only, with one stated exception. The keyring check writes a throwaway entry
(sshelf-doctor-probe, under the real sshelf service so it exercises the actual path) and
deletes it immediately, because a backend that reads but can’t write is a backend that fails the
first time a password is saved, and a read-only probe would pass. Everything else, including
the vault check, only reads.
Exit 0 unless something failed; warnings don’t fail the run. That split is what makes
sshelf doctor && ... usable in a script: fail means broken now, warn means it works but is
limited (a stale export, no agent for agent hosts, orphaned secrets). A per-check severity with
no exit-code contract would leave every caller inventing its own parser.
Orphan detection is vault-only, and says so. The keyring crate offers no portable way to
enumerate a service’s entries (macOS would need security dump-keychain, which prompts), so in
keyring mode the check reports that it did not run. Rejected: silently reporting ok, because a
clean bill of health nobody checked is worse than an honest gap.
Export staleness is compared by content, not mtime. The fragment renders deterministically (D-023), so identical content is an up-to-date file whatever the timestamps say, and a touched-but-unchanged file shouldn’t nag.
No --json in this release. The exit code already covers the scriptable case, and a
machine-readable report has no user yet; adding one now would freeze a schema before anything
consumes it. Demand-gated, like the rest.
D-026 · Transfer multi-select: positional marks, a serial queue, and mkdir (not mkdir -p)
Space marks the entry under the cursor and Ctrl-a marks everything the filter shows (pressing
it again clears every mark); Ctrl-s then sends the marked set, the loudest missing piece next
to a real file manager. Marks are positional (indices into the pane’s current listing) and are
dropped whenever that listing is replaced: a directory change, a refresh after a transfer, or a
listing error. Rejected: remembering marks per path across navigation, which needs a second
model of the remote tree and raises the question of what a mark means after the file behind it
changed. Positional marks are always either right or gone. Space therefore stops being a filter
character. Filenames with spaces still match by the rest of their name, and marking beats
a literal space at position one of a query.
A send becomes a queue, not parallel transfers: the worker owns one ControlMaster and runs one
sftp at a time, and one authenticated connection is the whole point of that design (D-019).
The queue is captured at send time, so later navigation can’t change what moves, and
sending consumes the marks, so the queue is now the record. A destination that already holds the
name is skipped and the queue continues (never-overwrite still holds, and one collision
shouldn’t cost the other nine); a genuine transfer failure stops the rest, because a broken link
or a full disk will break the next item too. The destination is refreshed once, when the queue
drains, rather than after every item, because a listing is a round trip.
Cancelling mid-queue now emits a Cancelled event. Before, the worker cancelled silently and
the screen, which blocks every other key while a transfer runs, stayed stuck in that state.
F7 (mc’s key, with Ctrl-f as an alias for terminals that keep the function keys) opens a
one-line input on the focused pane. It creates one directory in that pane’s current
directory: std::fs::create_dir locally, sftp’s own mkdir remotely, deliberately not
create_dir_all / mkdir -p. A name carrying a path is a mistake, not a shortcut, and silently
creating three intermediate directories from a typo is exactly the kind of surprise this tool
avoids. Names with /, control characters, ./.., or an existing entry are refused with the
input still open; an existing directory is never adopted, matching how transfers refuse to
overwrite. On success the listing refreshes and the new directory lands under the cursor.
D-025 · tmux integration: window/pane modes, stay in the picker, and why secrets never cross
One config key (tmux = "off" | "window" | "pane", default "off") plus a $TMUX check.
When both say yes, Enter spawns tmux new-window -d/split-window -d and sshelf keeps
running with focus on the picker; that is the feature, not a side effect. (-d arrived in
0.15.0. Before it, tmux switched to the new window and the picker only came back when that
session ended, which is not what the docs described.) The picker’s cost per connection is what makes people
stop reaching for it, and a tmux user wants four sessions, not four launches. Outside tmux, or
with the key off, the path is bit-for-bit the old one: tear down, exec(), exit to shell (D-001).
Frecency is persisted before the spawn for the same reason it is persisted before exec(): the
connection is gone from sshelf’s hands either way.
The ssh argv is handed to tmux as separate arguments after --, so tmux execvps it
directly and no shell re-splits an identity path containing a space.
The hard part is authentication. The askpass wiring normally rides on the child Command’s
environment, which a tmux window (a child of the tmux server, not of sshelf) never inherits.
tmux’s only way across is new-window -e KEY=VALUE, and those are the tmux client’s own
argv: world-readable via ps, which is precisely the leak D-002 exists to prevent. So the line
is drawn by content, not convenience:
- Key/agent hosts: a plain spawn, no
-eat all. - Stored-secret hosts:
-ecarriesSSH_ASKPASS,SSH_ASKPASS_REQUIRE=force,SSHELF_ASKPASS=1andSSHELF_HOST_ID. None is a secret: the id is an opaque ULID the helper trades for the real secret out of the keyring, exactly as it does after anexec(). - A queued 2FA code (
SSHELF_2FA_CODE) and the vault master passphrase (SSHELF_VAULT_PASSPHRASE) never cross. Those connections fall back toexec()in place, with a one-line reason printed after the TUI is down and before ssh starts. - tmux older than 3.0 has no
-e, so stored-secret hosts fall back there too; an unreadable or unparseabletmux -Vcounts as too old, since falling back is always correct. - A first secret to ask for (D-035): nothing is stored and the connect is going to ask on the terminal. A new window has no sshelf in it to ask, so that connection falls back as well.
Rejected: writing the code or passphrase to a temp file for the new window to read (a second
secret-at-rest path, with cleanup that a killed pane never runs); tmux setenv before spawning
(same argv exposure, plus it leaks into the whole session); and refusing tmux mode for every
secret-bearing host (it would exclude ordinary keyring password hosts, which are safe, for the
sake of two that aren’t). A unit test asserts the code and passphrase variables can never appear
in a generated tmux argv.
D-024 · Tailscale import shells out to the user’s CLI; MagicDNS names, suffix eligibility, add-only
sshelf import --tailscale fills the database from a tailnet, the inventory a homelab/small-fleet
user already curates, without sshelf growing a network client. It shells out to the user’s own
tailscale status --json and parses stdout. Rejected: the Tailscale HTTP API / an SDK, which
would mean an API key to store (violating the no-secrets-in-hosts.toml rule and the whole
secret-handling story), an HTTP + async dependency stack, and sshelf making network calls of its
own; and reading tailscaled’s local socket directly (unstable, root-ish, platform-specific).
Shelling out keeps the no-network posture intact: the binary runs only when the user runs the
subcommand, never at startup, on save, on a timer, or from the TUI. Binary resolution is
$SSHELF_TAILSCALE_BIN → PATH → /Applications/Tailscale.app/Contents/MacOS/Tailscale,
because the macOS app doesn’t put its CLI on PATH.
hostname = the MagicDNS FQDN, not a Tailscale IP: it survives IP churn, reads better in the
list, and is what Tailscale SSH expects, with the IP (IPv4 first) as the fallback when MagicDNS
is disabled for the tailnet. Eligibility is one rule: the peer’s DNSName must sit under the
tailnet’s own MagicDNSSuffix, which excludes Mullvad exit nodes and shared-in/foreign nodes
without special-casing either. Rejected: filtering by ExitNodeOption/OS/owner (a list of
special cases that ages badly). Expired peers are skipped; offline peers are not, because
Online is transient and an asleep laptop is still a real host. The tailnet becomes a site
(matched case-insensitively, created bare when new) and ACL tags become sshelf tags minus the tag:
prefix, so the tailnet’s own structure carries over instead of being flattened.
Import is add-only: existing hosts and sites are never updated or deleted, so re-running
converges to “0 added” and the user’s own edits (a user, a port, a password) are never
clobbered. Rejected: sync semantics (two-way reconciliation, deletion of departed nodes), which
needs a per-host record of provenance, and v1 deliberately stores nothing tailscale-specific
in hosts.toml: no node IDs, no keys. The JSON→hosts mapping is a pure function over &str
(mirroring import::parse_str), so the whole feature is fixture-tested without the binary,
a tailnet, or a network.
D-023 · Export writes our own ssh_config fragment; the user adds the Include line
sshelf export projects the database into ssh_config format so native tools (ssh/scp/sftp,
rsync, git, editor remote extensions) resolve sshelf hosts by name. The standing objection to
a tool with its own database is lock-in, and this removes it. The fragment is written to
sshelf’s config dir (ssh_config, next to hosts.toml), never under ~/.ssh; the user
adds the one Include line themselves, which keeps the “never edits ~/.ssh/config” promise
literal (sshelf still only ever reads it, to skip the hint when the Include is already there).
Rendering mirrors ssh::build_args (site defaults resolved, -i gated on key auth, port 22
omitted) minus sshelf-only plumbing: no StrictHostKeyChecking=accept-new (that exists to
keep the host-key prompt away from the askpass helper; plain ssh should keep the user’s
defaults) and no askpass wiring (exported password hosts prompt on the tty). extra_args are
CLI flags, not config keywords, so only exact -o Key=Value pairs translate; the rest is
preserved as an in-block comment rather than guessed at. Output is deterministic (name-sorted,
no timestamps) so the file churns only when the database changes. Once the file exists,
every hosts save refreshes it (best-effort, since derived data never blocks a save); creating it
is the opt-in, deleting it opts out. Host names that can’t be a safe Host pattern (glob/
negation/comment/quote characters) are skipped with a comment, since exporting them would match
other names; values are control-char-stripped so a crafted field can’t inject directives.
Rejected: writing into ~/.ssh/ (even a new file, since the promise is that sshelf never touches
that directory); auto-appending the Include (mutates the user’s config, the one hard no);
translating arbitrary flags to directives (lossy guessing); a config key for the export path
(existence-as-opt-in needs one well-known location).
D-022 · Interactive 2FA: collect the code before connect, inject it via the askpass helper
A connect that auto-supplies a stored secret runs ssh with SSH_ASKPASS_REQUIRE=force, which
routes every interactive prompt, including a server’s keyboard-interactive verification-code
step, to the askpass helper; the helper declined it, and a spike confirmed force gives no
terminal fallback, so the code prompt was answered empty and auth failed (a real user hit this).
A during-session popup is impossible (connect exec()s into ssh), and a PTY screen-scraper was
already rejected (D-019). So 2FA is handled the same way the password is: a per-host
requires_2fa flag makes connect show a small code popup before the exec() (while the TUI
is alive); the entered one-time code is passed to ssh via SSHELF_2FA_CODE (like the vault
passphrase already rides env), and the helper answers the non-secret prompt with it. The
helper’s routing: a password/passphrase-shaped prompt → the stored secret (unchanged, anti-phish
guard intact); any other prompt → the queued code; else decline. configure_askpass therefore
force-wires the helper when a secret exists or a code is queued (so key+2FA hosts work too).
The CLI direct-connect path (sshelf <host> / -), which has no TUI, prompts for the code on the
terminal before handoff. Rejected: storing the TOTP seed and generating the code ourselves
(puts the second factor in the same vault as the first, and needs a TOTP dep the project avoids);
auto-detecting 2FA with no flag (sshelf can’t probe the server’s auth methods without a separate
non-exec connection). Note: a host with no stored secret already prompts for the code inline
after handoff (no askpass forced), so the flag/popup mainly fixes the stored-secret case; an
encrypted key with no stored passphrase + 2FA should use an agent (else force askpass can’t
answer the passphrase prompt either). v1 is manual entry only.
D-021 · Port forwards are detached ssh -N processes tracked by PID
Background port forwards (Ctrl-f popup, F4 manager) must keep running after sshelf exits.
Each forward is one detached ssh -N -L|-R|-D <spec> process, reusing ssh::build_args +
ssh::configure_askpass (so keys/agent/ProxyJump/stored-password and site defaults all work as
connect does). It is spawned with std::os::unix::process::CommandExt::process_group(0) (std,
no new dep) and null stdin/stdout, which makes it survive both sshelf exiting (orphaned →
reparented to init) and the terminal closing (its own process group never receives the shell’s
SIGHUP). Nothing kills a forward on Drop or app shutdown, and that is what keeps it alive.
Validated by an M0 spike: a process_group(0) child with null stdio outlives its spawner
(PPID→1) in its own process group, and kill -TERM stops it.
There is no daemon. The running processes are the source of truth; forwards.json (mirrors
state.json: #[serde(transparent)] over a Vec, atomic_write 0600) is just a remembered
list of PIDs. reconcile re-validates each PID via ps -ww -o state=,command=: a forward stays
only if the process exists, isn’t a zombie (state != Z, so a dead-but-unreaped child sshelf
spawned this session is correctly seen as gone), and its command line still matches our
ssh ... <spec> (a PID-reuse guard, so a recycled pid is never counted alive or signalled).
Reconcile runs on startup, on opening the manager, and on the ~100ms event-loop tick while it’s
open. Readiness/errors: -o ExitOnForwardFailure=yes makes ssh exit non-zero on a bind failure;
spawn polls try_wait for ~2.5s and, on an early exit, maps the stderr (captured to a temp file,
not a pipe, so a long-lived ssh never gets SIGPIPE) to a friendly message (port in use,
privileged port, server refused, auth failed). A third kind, Dynamic (-D SOCKS), was added
alongside Local/Remote. Rejected: a worker thread per forward (the transfer model, unneeded here
because a forward has no ongoing protocol to service, just liveness); holding the Child for
try_wait (can’t track forwards from a previous session, and splits liveness into two code paths);
ssh -f (clean daemonize but hides the real PID, breaking the reuse guard and individual kill);
libc::setsid/nix::kill (a new dep the project avoids, since process_group(0) + shelling to
ps/kill, as sshelf already shells to ssh/sftp, is dep-free); kill-only for v1 (restart of a
dropped forward is deferred, but the spec is persisted, so it’s an easy fast-follow).
D-020 · Sites: one-per-host grouping with optional inherited SSH defaults
Hosts can belong to a Site (a data center / project), distinct from many-valued free-form
tags. A site is one per host and may carry optional shared SSH defaults (user,
port, jump_hosts (the bastion), identity_files) that members inherit at connect time
only where the host leaves that field unset (the host always wins). A bare site (name only)
is pure grouping. Auth is not inheritable (it stays per-host; inheriting it would change
which fields apply and surprise users, though a site can still carry a default identity that only
takes effect for key-auth members). Inheritance is computed by resolving a host into an
“effective host” (Host::with_site_defaults) at every Host→ssh-args boundary (connect, yank,
transfer master, CLI print/list-json), leaving ssh::build_args untouched, chosen over
threading &[Site] through build_args and its many callers/tests. Hosts reference a site by
name; an undefined name degrades gracefully (pure grouping, no inheritance, no error).
Stored in hosts.toml as [[site]] (sites before hosts; format_version unchanged, so old files
load with sites = []). The list groups by site when idle and shows a flat ·site· column
site:NAMEfilter while typing. Renames in the F3 manager cascade to member hosts; deleting a site clears members’site(self-healing) rather than leaving a dangling name. Rejected: a single special tag (too weak, with no inherited config); a separate sites file (one atomichosts.tomlis simpler and keeps the reference local).
D-019 · File transfer rides an ssh ControlMaster; sftp/scp as subprocesses
The dual-pane transfer screen moves files over the system sftp/scp binaries, not a Rust
SSH library: every pure-Rust option either pulls C deps (libssh2) or forces tokio and can’t
reuse sshelf’s SSH_ASKPASS/ProxyJump auth. To support password hosts without a fragile PTY,
sshelf authenticates once by opening a backgrounded ssh ControlMaster (reusing
build_args + the askpass env exactly as connect does); sftp/scp then ride it with only
-o ControlPath, so there is no re-auth and no per-file prompt. A spike against a local sshd
confirmed that (a) SSH_ASKPASS supplies the secret to open the master and (b) sftp/scp
ride it for put/get and recursive copies. The ride commands deliberately omit -p/-i/-J
(the master already carries them), which also avoids the ssh -p vs sftp/scp -P port-flag
clash. Rejected: ssh2/wezterm-ssh (C deps), russh/openssh-sftp-client (tokio + no askpass
reuse), and a PTY password screen-scraper (brittle, locale/version-dependent).
Update (transfers use sftp, not scp): listing and copying both run through sftp
(ls/get/put). scp was dropped after a filename with spaces failed in testing. OpenSSH 9+
scp speaks the SFTP protocol and takes the remote path literally, so shell-quoting it (needed
by legacy scp) injects literal quotes. sftp quotes via its own command parser consistently
across OpenSSH versions, so one quoting rule (shell_quote) is correct everywhere.
D-018 · Configurable hosts file in config; config file via flag/env only
A hosts_file key in config.toml relocates the host DB (editable via the F2 settings screen,
default under the config dir). The config file’s own location can’t be a config key
(bootstrap/circular), so it’s set with --config / $SSHELF_CONFIG only and shown read-only in
settings. The --config flag is plumbed by setting $SSHELF_CONFIG once at startup so every
Paths::resolve() (incl. subcommands) sees it uniformly. Vault/state stay in the XDG data dir,
so askpass is unaffected by a custom config. On hosts-file change, an existing target is adopted
(never overwritten) and config is committed only after the hosts step succeeds (so a bad path
can’t brick startup). Designed to grow (more settings fields later).
D-017 · Pick keys via a file browser; detect keys by header
The Key field cycles ~/.ssh keys with ←/→ and opens an in-TUI file browser on Enter
so users can pick a key anywhere (e.g. an AWS .pem in ~/Downloads) without typing a
path. Key discovery detects private keys by a PRIVATE KEY header rather than only a .pub
sibling, so .pem/keyless keys are found. Chosen over a path text field (the user explicitly didn’t
want to paste paths) and over scanning many fixed locations (a browser is more general).
D-016 · Auth-aware wizard with a single-key picker
The add/edit form shows only the fields relevant to the chosen auth method, and key auth uses
a picker over ~/.ssh keys (files with a .pub sibling) rather than a freeform path field.
Matches the user’s request and reduces clutter. Trade-off: the picker selects one key; a host
with multiple identity files keeps them on edit, but adding several is done via hosts.toml
(the model still supports Vec). Discovery uses OsString (no lossy UTF-8) so keys aren’t missed.
D-015 · askpass answers password + passphrase, matched by prompt shape
The helper now supplies the host’s stored secret for both login-password and key-passphrase
prompts (a host uses one auth method, so one secret suffices), enabling auto-supply for
encrypted keys. To prevent a keyboard-interactive server from phishing the secret, matching is
by OpenSSH prompt shape (ends-with password: / contains passphrase for), not a bare
substring. Connect wires SSH_ASKPASS only when a stored secret exists (wire_askpass).
D-014 · age vault uses scrypt (passphrase recipient), not Argon2id
The earlier plan said Argon2id; age’s passphrase mode actually uses scrypt + ChaCha20-Poly1305.
We use age’s built-in passphrase encryptor rather than composing a KDF/AEAD by hand (avoids
nonce-reuse/parameter footguns). Docs corrected to say scrypt.
D-013 · Secret backend chosen by SSHELF_VAULT_PASSPHRASE (v1)
OS keyring by default; if SSHELF_VAULT_PASSPHRASE is set, use the age vault instead. Chosen
over runtime keyring-availability detection + an interactive passphrase modal because it’s
deterministic, scriptable (headless/CI), and avoids a TUI passphrase prompt plus an askpass-side
decrypt in v1. Trade-off: headless users set the env var (shell profile / systemd). Auto-detect
fallback + interactive prompt are future enhancements. A set-password CLI provisions secrets
without the TUI.
D-012 · Project name: sshelf
Chosen over ssh-tui (generic), sssh (one keystroke from ssh, typo-prone), hopp (low
discoverability). sshelf = “a shelf for your SSH hosts”: brandable, memorable, still
contains “ssh” for search discoverability. Confirmed available on crates.io.
D-011 · Docs-in-sync rule
Every code/behavior change updates docs/ + docs/progress.md in the same change; the rule
lives in CONTRIBUTING.md. Rationale: keep a publishable, never-stale knowledge base for an
open-source project and its contributors.
D-010 · License: dual MIT OR Apache-2.0
Rust ecosystem norm (ratatui, ripgrep, crossterm). Maximizes downstream compatibility vs. single MIT or AGPL. AGPL rejected (limits commercial adoption for a CLI tool).
D-009 · Platforms: macOS + Linux only (v1)
exec() process replacement is Unix-only and the secret backends differ on Windows. Windows
would need a separate spawn+wait path + Credential Manager, so it is deferred to a later version.
D-008 · Frecency = use_count * exp(-decay_rate * days_since_last_used)
Mozilla Places style. Simple, explainable, self-adjusting. Idle list sorts by frecency;
while typing, fuzzy score dominates and frecency breaks ties. decay_rate (default 0.2) is
configurable. Rejected: pure recency (ignores frequency), pure alphabetical (ignores usage).
D-007 · Read-only import via ssh2-config
Best-maintained Rust SSH-config parser. It intentionally skips Match/Include, so import
must warn and degrade, not silently drop. We never write back to ~/.ssh/config.
D-006 · Config/data paths: etcetera base strategy (XDG everywhere)
~/.config/sshelf on both macOS and Linux (honoring XDG env vars). Rejected directories
crate’s native strategy, which buries macOS files in ~/Library/Application Support, worse
for a hand-editable CLI tool. State/vault go in the XDG data dir.
D-005 · Host DB format: TOML (hosts.toml), not SQLite
Human-readable and hand-editable, which matches the “my own transparent store” intent; host counts are small (tens to hundreds). Atomic writes (temp+rename) prevent corruption. One research stream suggested SQLite for indexed frecency queries; rejected for v1 as overkill, but it’s a clean future migration if scale demands.
D-004 · Frecency state separate from hosts.toml (state.json)
Mutable counters churn on every connect; keeping them out of the user-owned host file keeps
that file stable and diff-friendly. Keyed by stable host id so renames preserve history.
D-003 · Two-tier secrets: OS keyring primary + age vault fallback
keyring (Keychain / Secret Service) for desktops; an age-encrypted vault (master passphrase,
in-memory per session) for headless/minimal Linux with no Secret Service daemon, exactly the
boxes this tool targets. age (used by atuin) chosen over hand-rolled Argon2+ChaCha to avoid
error-prone crypto. Secrets are never stored in hosts.toml.
D-002 · Password auto-supply: SSH_ASKPASS (+ REQUIRE=force), not sshpass
Our own binary is the askpass helper (detected via SSHELF_ASKPASS=1; ssh calls it as
sshelf "<prompt>"). No external dependency; secret never appears in ps/argv. Mandatory
consequence: the helper must inspect argv[1] and only answer password prompts, and we set
-o StrictHostKeyChecking=accept-new to keep host-key prompts away from it. Validated by the
M0 spike before anything builds on it. Rejected sshpass: not installed by default, exposes
the password in the process table.
D-001 · Connect = tear down TUI then exec() into ssh (exit-to-shell)
User chose exit-to-shell over return-to-list. exec() (process replacement) gives ssh the
real TTY cleanly. Consequence: nothing runs after exec(), so frecency is persisted before
the handoff. Rejected spawn+wait (would be needed only for return-to-list).
D-000 · Stack: Rust + ratatui + crossterm, sync event loop, component pattern
Matches atuin’s look/feel (user preference). ratatui 0.30 requires Rust 1.88+
(rustup update mandatory). Synchronous crossterm::event::read() loop, with no tokio, since the
only long-running task (the SSH session) happens after the TUI exits. Component-per-screen
structure over the Elm pattern for this app’s modal UI.
Packaging & distribution
How sshelf ships to Homebrew (macOS + Linux), Debian/Ubuntu (.deb), RedHat/Fedora
(.rpm), and crates.io, for x86_64 and arm64. Releases are driven from GitHub Actions on
a vX.Y.Z tag.
Chosen stack:
dist(cargo-dist) builds the binaries for all targets, makes the GitHub Release (tarballs + checksums), generates the Homebrew formula and pushes it to your tap, and emits a curl-able shell installer.- Hand-written companion workflows attach what dist doesn’t build to the same Release, each
triggered via
workflow_runafter dist’s “Release” completes (so they never race to create it):release-deb.yml(.deb, viacargo deb),release-rpm.yml(.rpm, viacargo generate-rpm, built as a static musl binary), andrelease-crates.yml(cargo publishto crates.io). - clap generates shell completions + a man page (via
sshelf completions/sshelf man). - crates.io: cargo-dist has no built-in crates.io publish job, so
release-crates.ymlrunscargo publish(needs aCARGO_REGISTRY_TOKENrepo secret; skips cleanly if it’s unset).
GitHub user is max-rh; the repo is github.com/max-rh/sshelf; the Homebrew tap is
max-rh/homebrew-tap.
Contents
- Prerequisites
- Target matrix (x86 + arm)
- Homebrew + tarballs via dist
- Debian/Ubuntu
.deb - Shell completions & man page (clap)
- macOS signing, no Apple program needed
- Cross-compilation reference
- Release checklist
- Appendix: manual Homebrew formula & APT repo
1. Prerequisites
Set in Cargo.toml before the first release:
[package]
# ...existing fields...
repository = "https://github.com/max-rh/sshelf"
homepage = "https://max-rh.github.io/sshelf" # the GitHub Pages docs site
readme = "README.md"
# `exclude` keeps the published crate lean (drops docs/, .github/, examples/, the gif, etc.).
# `authors` is optional (cargo no longer auto-fills it): omit it, or use a project ALIAS,
# never your personal email: this file is public on GitHub and copied into every .deb.
Email/privacy: the
maintainerin[package.metadata.deb]is shipped in every.deband therepositorylink already gives users a way to reach you (Issues). Use a dedicated alias (e.g. a Gmail+tag, a forwarding address, orsshelf@yourdomain), not your private inbox.
Already in place in this repo:
[package.metadata.deb](forcargo deb) and the.github/workflows/release-deb.ymlworkflow.sshelf completions <shell>andsshelf mansubcommands (clap).- Dual
MIT OR Apache-2.0license, committedCargo.lock, MSRV1.88.
Conventions / facts that matter:
- A release is a git tag
vX.Y.Zwhose number matchesCargo.toml’sversion. - Ship prebuilt binaries (Debian/Ubuntu’s packaged
rustcoften predates the MSRV 1.88). - sshelf
execsssh→ the.debdepends onopenssh-client; macOS hassshbuilt in. - Linux secrets use a pure-Rust Secret Service client with no
libdbus/OpenSSL/tokioC build deps, so cross-compiling is easy and the.debneeds no-devpackages. The Secret Service daemon is aRecommends(theage-vault fallback exists).
2. Target matrix
| OS / arch | Rust target | Built by |
|---|---|---|
| macOS Apple Silicon | aarch64-apple-darwin | dist, on an arm64 macOS runner |
| macOS Intel | x86_64-apple-darwin | dist (cross on the arm64 runner) |
| Linux x86_64 (Debian/Ubuntu amd64) | x86_64-unknown-linux-gnu | dist + .deb on ubuntu-22.04 |
| Linux arm64 (Debian/Ubuntu arm64) | aarch64-unknown-linux-gnu | dist + .deb on ubuntu-24.04-arm |
Linux x86_64/arm64 static (the .rpm) | *-unknown-linux-musl | release-rpm.yml (cargo generate-rpm) |
- GitHub’s free arm64 Linux runners (
ubuntu-24.04-arm, GA for public repos since Aug 2025) build arm64 natively, no QEMU. (They aren’t available to private repos on the free tier.) - macOS runners:
macos-14/macos-15are arm64,macos-13is the last Intel one. dist cross-compilesx86_64-apple-darwinon an arm64 runner (both SDKs are present). *-gnuis correct for.deb;*-muslgives a fully static tarball that runs on any distro (nice for the generic download and Homebrew-on-Linux), but isn’t used for.deb.
3. Homebrew + tarballs via dist
One-time setup
cargo install cargo-dist --locked # installs the `dist` binary
dist init # interactive; safe to rerun anytime
Answer dist init with:
- CI: GitHub.
- Installers:
shellandhomebrew. - Targets:
aarch64-apple-darwin,x86_64-apple-darwin,x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu(add the two*-musltargets if you want static tarballs). Decline Windows: sshelf is Unix-only, so removex86_64-pc-windows-msvcif it’s added. - Updater: no (
install-updater = false), since Homebrew/apt self-update and the shell-installer audience is small. - Homebrew tap:
max-rh/homebrew-tap. Create that repo first and initialize it with a README (so it has a default branch dist can push the formula to). Then add aHOMEBREW_TAP_TOKENsecret to thesshelfrepo, a PAT with write access to the tap repo, because the defaultGITHUB_TOKENcan’t push to another repo. Without it thepublish-homebrew-formulajob fails.
dist init writes its config to dist-workspace.toml and generates
.github/workflows/release.yml. Let dist init/dist generate manage it (it pins
cargo-dist-version to your installed version). The config:
[workspace]
members = ["cargo:."]
[dist]
cargo-dist-version = "0.32.0" # managed by dist; don't hand-edit
ci = "github"
installers = ["shell", "homebrew"]
tap = "max-rh/homebrew-tap"
targets = [
"aarch64-apple-darwin", "x86_64-apple-darwin",
"x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu",
]
publish-jobs = ["homebrew"]
install-path = "CARGO_HOME"
install-updater = false
Drop the Windows target:
dist initaddsx86_64-pc-windows-msvcby default. sshelf is Unix-only (the connect path usesexec()), so the Windows build can’t compile. Remove that target fromtargets, leaving the four above.
Releasing
# bump version in Cargo.toml, commit, then:
git tag v0.1.0
git push origin v0.1.0
The tag triggers release.yml (dist): it builds every target, creates the GitHub Release
(tarballs + dist-manifest.json + shell installer), and updates the formula in
max-rh/homebrew-tap. When that workflow finishes, release-deb.yml runs via workflow_run
and attaches the .debs to the Release (§4), sequenced rather than racing.
Users then:
brew install max-rh/tap/sshelf # macOS or Linux, picks the right arch automatically
# or the shell installer dist prints in the release notes:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-rh/sshelf/releases/latest/download/sshelf-installer.sh | sh
macOS signing: Developer ID signing + notarization need the paid Apple Developer Program and are optional; a CLI installed via Homebrew runs fine unsigned. Do add a free ad-hoc
codesignstep for a stable signature. See §6 for the full free-vs-paid breakdown.
Completions in Homebrew: dist’s generated formula installs the binary. Shell completions are available immediately via
sshelf completions <shell>(§5); the.debinstalls them system-wide. If you want Homebrew to install them too, use the manual formula in the appendix withgenerate_completions_from_executableinstead of dist’s formula.
4. Debian/Ubuntu .deb
dist doesn’t build Debian packages, so a companion workflow does. The package metadata is
already in Cargo.toml:
[package.metadata.deb]
maintainer = "max-rh <max-rh@mail.com>" # public alias, not a personal inbox
depends = "$auto, openssh-client" # sshelf execs ssh
recommends = "gnome-keyring" # Secret Service daemon (vault is the fallback)
section = "utils"
# ...assets: the binary, README/SECURITY, and the generated completions + man page...
The workflow .github/workflows/release-deb.yml builds
natively on each arch (ubuntu-22.04 for amd64, ubuntu-24.04-arm for arm64), generates
completions + the man page with the sshelf subcommands, runs cargo deb --no-build, and
attaches target/debian/*.deb to the Release. It triggers on workflow_run, i.e. after
dist’s release.yml completes, so the two never race to create the Release: dist owns creation,
this only attaches. (A release: published trigger wouldn’t fire, because dist creates the
Release with GITHUB_TOKEN, and GITHUB_TOKEN-created events can’t trigger downstream workflows.)
Outline:
on:
workflow_run: { workflows: ["Release"], types: [completed] }
jobs:
deb:
# only after a successful, tag-triggered Release run; head_branch is the tag
if: github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
strategy:
matrix:
include:
- { arch: amd64, os: ubuntu-22.04 }
- { arch: arm64, os: ubuntu-24.04-arm }
steps:
- uses: actions/checkout@v4
with: { ref: "${{ github.event.workflow_run.head_branch }}" } # the release tag
- run: cargo build --release --locked
- run: | # generate the packaged extras
bin=target/release/sshelf
"$bin" completions bash > dist-extra/sshelf.bash # + zsh, fish
"$bin" man > dist-extra/sshelf.1
- run: cargo deb --no-build # .deb arch == runner arch
- uses: softprops/action-gh-release@v2
with:
tag_name: "${{ github.event.workflow_run.head_branch }}"
files: target/debian/*.deb
Users install a downloaded package with:
sudo apt install ./sshelf_0.1.0-1_amd64.deb # resolves deps (openssh-client, ...)
For a true apt install sshelf (no file download), host a signed APT repo; see the
appendix. That’s the most involved channel; the .deb-on-Releases above covers
most users.
4b. RedHat/Fedora .rpm (static musl)
Same shape as the .deb, with two differences. The package metadata is in Cargo.toml
([package.metadata.generate-rpm], built by cargo-generate-rpm),
and the binary is built static musl (*-unknown-linux-musl) so one .rpm runs on any RPM
distro (Fedora, RHEL/Rocky/Alma, openSUSE) regardless of glibc version. (sshelf is
distro-agnostic at runtime: it only shells out to the system ssh/sftp/ps/kill, all OpenSSH/
procps, and the Linux keyring is the pure-Rust Secret Service with the age-vault fallback, so none
of it is Debian- or RPM-specific.) auto-req = "no" stops rpm from adding bogus shared-lib
Requires to a static binary, so the metadata declares openssh-clients explicitly.
.github/workflows/release-rpm.yml mirrors the .deb
workflow: workflow_run after dist’s Release, a matrix of x86_64 (ubuntu-22.04) and aarch64
(ubuntu-24.04-arm, native), rustup target add <musl>, cargo build --target <musl>, generate
completions/man, then cargo generate-rpm --target <musl> (which rewrites the target/release
asset paths to the per-target dir) and attaches the .rpm. Users install with:
sudo dnf install ./sshelf-0.8.0-1.x86_64.rpm # or .aarch64.rpm
Why musl, not glibc like the
.deb: a glibc binary built on the CI runner only runs on distros with an equal-or-newer glibc, which excludes older RHEL. Static musl sidesteps that entirely. (The.debkeeps glibc since Debian/Ubuntu users build/run on a known-recent glibc.)
4c. crates.io (cargo install sshelf)
cargo-dist has no built-in crates.io publish job (publish-jobs only knows homebrew/npm/
custom ./jobs), so publishing is a separate companion workflow,
release-crates.yml: workflow_run after the Release,
then cargo publish --locked. It needs a CARGO_REGISTRY_TOKEN repo secret (a crates.io API
token, ideally scoped to the sshelf crate); the step skips cleanly if the secret is unset, so the
workflow stays green before it’s added. The crate’s Cargo.toml carries the required metadata
(description, license, keywords, categories, repository, homepage) and an exclude
that drops docs//.github//examples/ from the published tarball. cargo publish builds from
the tag’s source, so it’s independent of the release binaries.
cargo install sshelf # once published
5. Shell completions & man page
sshelf generates these itself (clap), so packaging needs no extra tooling:
sshelf completions bash # also: zsh, fish, elvish, powershell
sshelf man # roff man page on stdout
.debships them system-wide (/usr/share/bash-completion/...,/usr/share/man/man1/...) (generated in the deb workflow, §4, and listed in[package.metadata.deb].assets).- Homebrew (dist formula): users can
source <(sshelf completions zsh), or switch to the manual formula (appendix) which auto-installs viagenerate_completions_from_executable(bin/"sshelf", "completions")andman1.install. - Tarball users: the binary is self-sufficient, so run the subcommands as needed.
Implementation: src/main.rs builds the clap::Command with Cli::command() and feeds it to
clap_complete::generate(...) / clap_mangen::Man::new(...). No build.rs needed.
6. macOS signing, and why you don’t need the $99 Apple program
Developer ID signing + notarization require the paid Apple Developer Program ($99/yr). You
do not need it to ship sshelf, because it’s a CLI distributed via Homebrew, not a GUI
app. Here’s the free path and exactly what (if anything) you give up.
What macOS actually enforces:
- Apple Silicon refuses to run a binary with no signature, but a free ad-hoc signature satisfies it, and the macOS toolchain applies one automatically when it links the binary. (Intel Macs don’t even require that.)
- Gatekeeper’s “unidentified developer” block only hits files carrying the
com.apple.quarantinexattr, which browsers set on download.curl,git, and Homebrew don’t set it for CLI formulae, so abrew install-ed binary runs with no Gatekeeper prompt, signed or not. (Homebrew’s recent tightening, deprecating--no-quarantineand disabling failing casks in Sept 2026, targets GUI.appcasks, not CLI formulae like sshelf.)
Free distribution that “just works” (recommended order):
-
Homebrew:
brew install max-rh/tap/sshelf. No quarantine, no Gatekeeper prompt, no Apple account. This is how most open-source Rust CLIs ship, and it’s the main path. -
Build from source:
cargo install --git https://github.com/max-rh/sshelf, or a formula withdepends_on "rust" => :build. Compiled locally → no signing questions at all. -
Ad-hoc sign in CI (free, recommended), the chosen hardening. Guarantees a stable signature on every macOS artifact, no cert/account/Apple-program. Verified on an Intel build: the default
cargo buildleaves the binary “not signed at all”; one command fixes it:codesign --sign - --force target/<triple>/release/sshelf # ad-hoc, free codesign -dvv target/<triple>/release/sshelf 2>&1 | grep Signature # -> Signature=adhoc(arm64 binaries are auto ad-hoc-signed by the linker, since Apple Silicon requires it to run; this step also covers the cross-built x86_64 and settles the Keychain point below.)
Wiring it into dist: dist builds macOS on macOS runners and generates
.github/workflows/release.yml. Add this step to that file’s macOS build job, right after the build, so both arches get a stable ad-hoc identity:- name: Ad-hoc sign macOS binaries (free, stable identity) if: runner.os == 'macOS' run: find target -type f -name sshelf -perm +111 -exec codesign --sign - --force {} \;Re-apply it if you re-run
dist init(which regeneratesrelease.yml). The Linux/.debside needs no signing.
The one thing you lose without paying: a user who downloads the release .tar.gz directly
in a browser gets it quarantined, so Gatekeeper blocks it until they clear it once:
xattr -dr com.apple.quarantine "$(command -v sshelf)" # or: right-click the file → Open
Document that, or just steer direct-download users to Homebrew. Notarization is the only thing that removes this for direct downloads, and that needs the paid program.
Keychain prompt (sshelf-specific): the per-connect Keychain prompt happens when the signature
is unstable (re-built dev binaries) or absent. A released, ad-hoc-signed binary has a
stable identity, and sshelf’s askpass child is the same binary file as the parent, so the
Keychain ACL one creates is honored by the other → no prompt. If a user still hits keychain
friction, the age vault (SSHELF_VAULT_PASSPHRASE) bypasses the OS keychain entirely, the
guaranteed-free fallback (see docs/security.md).
If you ever do pay ($99/yr) for friction-free direct downloads: sign with a Developer ID Application cert under the hardened runtime, then notarize (dist can automate this from CI secrets):
codesign --force --options runtime --timestamp \
--sign "Developer ID Application: NAME (TEAMID)" target/<triple>/release/sshelf
ditto -c -k --keepParent target/<triple>/release/sshelf sshelf.zip
xcrun notarytool submit sshelf.zip --key AuthKey.p8 --key-id KEYID --issuer ISSUER_UUID --wait
(You can’t stapler staple a bare binary/zip, only .app/.dmg/.pkg, but a notarized zip
is fine for Homebrew; ship a stapled .pkg for offline direct downloads.)
7. Cross-compilation reference
- Native (what dist + the deb workflow use): build each target on a runner of that arch.
cross(cross-rs/cross):cross build --release --target aarch64-unknown-linux-gnu(Docker-based; handles the linker/sysroot).- Plain cargo cross-link (Linux):
(Pure-Rust apart from libc, so no othersudo apt-get install -y gcc-aarch64-linux-gnu rustup target add aarch64-unknown-linux-gnu CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ cargo build --release --target aarch64-unknown-linux-gnu-devlibs are needed.) - macOS universal binary: build both Darwin targets, then
lipo -create -output sshelf <arm64> <x86_64>.
8. Release checklist
- Bump
versioninCargo.toml; update aCHANGELOG.md. - CI green (
cargo test,clippy -D warnings,cargo fmt --check). git tag vX.Y.Z && git push origin vX.Y.Z.- Watch
release.yml(dist → tarballs + Homebrew tap + shell installer); when it finishes,release-deb.ymlruns viaworkflow_runand attaches the.debs. macOS artifacts are ad-hoc signed (§6). - Smoke-test one install per channel and connect to a host from inside the TUI (the
real-TTY acceptance check in
docs/progress.md):brew install max-rh/tap/sshelfsudo apt install ./sshelf_*_amd64.debsudo dnf install ./sshelf-*.x86_64.rpmcargo install sshelf(after the crates.io publish lands)
9. Appendix
Manual Homebrew formula (alternative to dist’s)
Use this if you want Homebrew to install completions + the man page, or prefer not to run dist.
Put it in max-rh/homebrew-tap as Formula/sshelf.rb:
class Sshelf < Formula
desc "TUI for managing and connecting to SSH hosts"
homepage "https://github.com/max-rh/sshelf"
version "0.1.0"
license any_of: ["MIT", "Apache-2.0"]
on_macos do
on_arm { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-aarch64-apple-darwin.tar.gz"; sha256 "..." }
on_intel { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-x86_64-apple-darwin.tar.gz"; sha256 "..." }
end
on_linux do
on_arm { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-aarch64-unknown-linux-gnu.tar.gz"; sha256 "..." }
on_intel { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-x86_64-unknown-linux-gnu.tar.gz"; sha256 "..." }
end
def install
bin.install "sshelf"
generate_completions_from_executable(bin/"sshelf", "completions")
(man1/"sshelf.1").write Utils.safe_popen_read(bin/"sshelf", "man")
end
test do
assert_match "sshelf", shell_output("#{bin}/sshelf --version")
end
end
(brew bump-formula-pr can update the version + SHAs on each release.)
Signed APT repository (apt install sshelf)
Host a GPG-signed repo (e.g. on GitHub Pages) with reprepro:
gpg --full-generate-key
gpg --armor --export YOUR_KEY_ID > sshelf-archive-keyring.asc
# apt/conf/distributions: Codename: stable / Architectures: amd64 arm64 / Components: main / SignWith: YOUR_KEY_ID
reprepro -b apt includedeb stable sshelf_0.1.0-1_amd64.deb sshelf_0.1.0-1_arm64.deb
# publish the ./apt tree (dists/, pool/, signed Release/InRelease) to Pages
Users:
curl -fsSL https://max-rh.github.io/sshelf-apt/sshelf-archive-keyring.asc \
| sudo tee /usr/share/keyrings/sshelf.asc >/dev/null
echo "deb [signed-by=/usr/share/keyrings/sshelf.asc] https://max-rh.github.io/sshelf-apt stable main" \
| sudo tee /etc/apt/sources.list.d/sshelf.list
sudo apt update && sudo apt install sshelf
An Ubuntu PPA (Launchpad) is the native alternative but requires vendoring crates
(cargo vendor, dh-cargo) because Launchpad builders have no network, which is more work than the
signed-repo-of-prebuilt-.debs above for the same apt install UX.
Sources
- dist (cargo-dist): https://opensource.axo.dev/cargo-dist/
- GitHub arm64 Linux runners GA (public repos): https://github.blog/changelog/2025-08-07-arm64-hosted-runners-for-public-repositories-are-now-generally-available/
cargo-deb: https://github.com/kornelski/cargo-deb- Apple notarization: https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution
- Homebrew Formula Cookbook: https://docs.brew.sh/Formula-Cookbook
Progress log
Reverse-chronological. Newest entry on top. Every change to the project adds an entry here (the docs-in-sync rule). Keep entries short: what changed, why, and what’s next.
Current milestone: v0.16.0, secrets from a password manager. v0.15.0 (add a host from an ssh command, save the secret on first connect, and the upload fix) is tagged on Sep 14 and not pushed.
2026-09-14: v0.15.0, add a host from an ssh command, and save the secret on first connect
Two features, one bounded fix, and a tmux change, released together with the upload fix below.
sshelf add --from-ssh. The new sshcmd module reads an ssh command line with OpenSSH’s own
option grammar and maps it onto a host: user@host or an ssh:// URL, -l, -p, -i, -J, and
password auth when -o PasswordAuthentication=yes or PreferredAuthentications=password says
so. Everything else goes into extra_args, quoted only where a word needs it so the connect-time
split gives the same words back, and -v, -N, -f, -o StrictHostKeyChecking and a few more
are dropped with a note each. It opens the add form filled in (Wizard::prefill, focus on the
password for a password host), or with --quiet adds it the way a flag-built add does. The
parser follows ssh in reading options after the destination, since ssh re-reads them, so only the
first plain word after the destination counts as the remote command that gets refused. D-034.
The pipe case took more than expected. crossterm 0.29 opens /dev/tty when stdin isn’t a
terminal, but on macOS its event source registers that with kqueue, and the form stopped at
Failed to initialize input reader. A small kqueue probe in a real terminal showed /dev/tty
failing with EINVAL while /dev/ttys004 worked, so reopening /dev/tty onto fd 0 would have
failed the same way. The prefilled path reads what it needs from the pipe, then dup2s the device
ttyname reports for stdout (or stderr) onto fd 0 before the TUI starts. With no terminal at all
the host is added the --quiet way, with a note.
The secret, saved on first connect. A connect to a password host with nothing stored, or to a
key host whose key needs a passphrase, asks on the terminal before the handoff, stores the answer,
proves it with one ssh ... exit, and removes it again if that fails. An encrypted key is probed
with BatchMode=yes first, so a key already in the agent is never asked about. 2FA hosts store
without the check. Both exec paths now share one tail in main.rs (handoff), and the terminal
reader behind the 2FA prompt is shared with the new secret prompt without changing the code
prompt. In the TUI, a 2FA host that would be asked skips its code popup so both questions come on
the terminal, secret first. tmux mode falls back in place with its own reason. D-035.
A refused stored secret says so. Every wired command carries SSHELF_CONNECT_ID, and the
helper leaves askpass-<id> in the runtime directory the first time it answers a secret prompt.
The same prompt again prints one line naming set-password and ^e, and declines. The secret
stays. The marker holds the prompt rather than nothing, so a host with two encrypted keys isn’t
told its passphrase was refused when ssh moves on to the second key. The helper’s stderr reached
the terminal of a real connect, so the NumberOfPasswordPrompts=1 fallback wasn’t needed. The
runtime directory lookup moved from the transfer worker to paths::runtime_dir.
tmux -d. tmux_connect_args now passes -d for windows and panes both, so focus stays on the
picker, which is what search-connect.md and index.md described all along. I changed the code
rather than the docs: firing off several hosts in a row is the point of the mode. D-025 reworded.
Found on the way. OpenSSH 10.3 prints at most 100 bytes of a key’s path in its passphrase prompt
(seen directly: a 120-byte path came through cut at .../scratch), and the helper matches the path
exactly, so a stored passphrase for a key with a longer path is never supplied. The first connect
now skips such keys instead of asking for a passphrase and then calling a correct one refused.
Changing the helper’s matching is a separate piece of work. Also separate: an error message with a
| in it prints as a bare Error:, because the shared printer drops those lines when it folds a
toml diagram. The empty-stdin messages of set-password and --password-stdin already did that
before this release; the new --from-ssh message is printed directly to avoid it.
Verification. Unit tests cover the parser table, the add routing and the flag conflicts,
Wizard::prefill, the trigger matrix, the probe and verify exit rules, the connect id on the wired
and unwired paths, and the marker. tests/add_from_ssh.rs drives the real binary: the motivating
line with --quiet gives a key host with ~/Downloads/dev-ooblek-privco.pem, user ubuntu, no
extra args and the expected print-command argv, and stdin carries the line and then the secret.
tests/first_connect_e2e.rs runs (ignored) against a rootless sshd with password auth on: a wrong
password is refused and nothing is kept, Enter skips, an encrypted key is asked for, checked and
kept while a ps watcher finds the passphrase in no argv, a wrong passphrase is removed, a key in a
throwaway agent is never asked about, an unencrypted key never prompts, a 2FA host asks for the
password before the code and says it wasn’t checked, and a stale stored password prints the
refused line once and stays stored.
By hand, in a private tmux server with a throwaway vault: the piped form opens filled in with focus
on Name and Esc adds nothing; the password prompt reads with echo off and a ps scan across the
store and verify found nothing; Enter on an encrypted-key host in the TUI asks, saves and logs
in, and the second connect goes straight in; in tmux mode a password host with nothing stored
prints the new reason and connects in place, and a key host opens a background window while the
picker keeps focus; a stale stored password prints the refused line on the terminal, and the
transfer screen says the stored password was refused. ~/.ssh hashed the same before and after.
The two things the rootless sshd couldn’t show were checked afterwards against a real password
server, lscr.io/linuxserver/openssh-server in Docker, which offers keyboard-interactive as well.
A wrong password on first connect is refused and nothing is kept. The right one prints
saved password, the server logs Accepted password for the verify and again for the session,
and the next connect goes straight in; a ps scan across the prompt, the store and the verify
found the password in no argv. The TUI path asks after teardown, saves, and lands in a shell. A
stale stored password prints the refused line once and stays stored. With the secret in the macOS
Keychain instead of the vault (a throwaway entry, deleted afterwards), tmux mode opened the host in
a background window that logged in through the helper while the picker kept focus, and the debug
build’s helper read the entry without an approval prompt.
2026-09-14: uploads install with a no-replace link, like downloads always have
A reader on Reddit read the comment in transfer/screen.rs saying uploads cannot use the
install step downloads use, and asked what happens if the remote destination changes between
the listing check and the transfer. Nothing did. put creates and truncates, so a file that
appeared on the server after the check was overwritten, which is the one thing the transfer
screen promises never to do.
The docs were also optimistic about the size of that window. docs/transfer.md said the
listing is refreshed immediately before each send, and it is, but the refresh lands too late
for that send’s own check: send() checks the listing the pane is already showing. So the
first item of a queue was checked against whatever was last on screen, and later items against
a listing taken before the previous transfer started.
An upload now does what a download does. put writes a .sshelf-part- temporary into the
destination directory, and sftp’s ln installs it under the real name, which the server
refuses if anything is there, symlinks included. A refused link is followed by an ls to find
out whether the name was taken (protocol 3 reports every refusal as Failure), and the entry
comes back as the same skip the pre-flight check raises. Where the server cannot link at all,
the temporary is renamed onto the name the ls just showed free, the remote twin of the exFAT
fallback downloads have. A cancel or a failure removes the remote temporary.
Folders are now the only send resting on the listing alone, so the refusal to send into a
listing cut short at 50,000 entries narrowed to folders; single files go into such a directory
fine. transfer/e2e.rs covers the new path against a real sshd, including that OpenSSH takes
the link rather than the fallback. D-033.
2026-09-12: the first three issues off the tracker
Three reports came in on Sep 10, the first real issue traffic the project has had. All three are fixed here; a fourth (a script uploaded to the host on connect) is a feature request and is logged rather than built.
#20, the remote pane unusable on an AD/LDAP host. list_remote parsed ls -la by
whitespace column. Under a plain -l, sftp prints the server’s listing line verbatim, and
that line spells the owner and group as names, so a group called domain users was two
columns, everything after it shifted right, the size was read off the month, and every filename
came back with the tail of a timestamp on the front. Navigation broke with it. The listing asks
for ls -lan now: -n makes the client format the line from the file attributes and the ids
come out numeric, so no remote naming convention can move the columns again. The parser also
stopped reading an unparseable size as zero and skips the line instead, which is the tripwire
that would have caught this the first time. D-031; reported by @ReubenM, who also named the fix.
#18, transfer asking for a passphrase it could not take. sshelf holds the terminal in raw
mode, and OpenSSH reads a passphrase from /dev/tty rather than stdin, so closing the
ControlMaster’s stdin never stopped it asking. On a key host with nothing stored and nothing in
the agent, Enter passphrase for key '...' was painted over the TUI’s hint line while every
keystroke went to sshelf’s event loop. It could not be answered, the master sat there for the
full thirty-second handshake, and the screen then reported a timeout that blamed the password.
Both the master and the port-forward spawner now pass -o BatchMode=yes when no askpass helper
is wired, ahead of build_args so it beats anything in extra_args. ssh fails in about a
second instead, and ssh::classify_auth_error turns the bare Permission denied (publickey)
into the two ways out: ssh-add the key, or store its passphrase with ^e. D-032.
#19, the port-forward popup reading as broken. Not reproducible as filed: typing, ←/→
and ^s all work. Two real things underneath it, though. The footer promised ←/→ change on
every row, but the arrows only change the Type chooser; on a text field they walk a cursor,
which over an empty field looks exactly like a dead key. The hint is per-row now. And every
unhandled Ctrl-/Alt- combo fell through to the focused text field and inserted its bare letter,
so Ctrl-a Ctrl-u Ctrl-w left auw behind. The same leak was in the host wizard, the settings
screen and the sites editor; all four take the same guard.
Not done: #17, uploading a script to the host on connect. It is a sibling of the per-host
remote_command item already on the list, and it needs the same design work. extra_args is
shared by the transfer master, the forward builder and sshelf export, so the obvious
LocalCommand workaround would fire on every listing and leak into the exported config. Both
want one brief.
Next: v0.15.0, secrets from a password manager.
2026-09-09: security hardening, thirteen findings closed
An outside review read the source, the configuration, the history, the dependencies and the CI
at ce7d6ed and came back with thirteen things worth fixing. None of them was a remote code
execution or a leak in the default setup, but two undercut promises the docs were already
making, so this is a minor release rather than a patch, and it carries no new features.
The askpass helper now knows which secret it holds. It used to answer any prompt ending in
password: and any prompt containing passphrase for with the one stored value, whichever kind
that value was. The text of a keyboard-interactive prompt is written by the server, and
Password: is a perfectly ordinary shape, so a host that rejected your key could ask for a
password and be handed the key’s passphrase. configure_askpass now also sets
SSHELF_SECRET_KIND (password, passphrase or agent) and, for key hosts,
SSHELF_IDENTITY_FILES. The helper answers a password prompt only for a password host, and
OpenSSH’s own Enter passphrase for key '<path>': only for a key host whose -i list holds
that path. Anything secret-shaped of the wrong kind is declined, and never answered with the
queued verification code instead. A missing or unreadable kind declines everything. The third
kind, agent, exists so an agent host with a verification code still answers the code prompt
while refusing both secret shapes.
Key hosts pin public-key auth. -o PreferredAuthentications=publickey, or
publickey,keyboard-interactive for a host that needs a verification code. A server can no
longer steer a key host into a password prompt at all. This is the first of two behaviour
changes: a key host that quietly fell back to a password now fails with the server’s
“Permission denied (publickey)”.
A jump hop never sees the helper. ssh starts the hop as a child, which inherits
SSH_ASKPASS, and it forwards only -l, -p, -J, -F and -v from the destination, so
nothing on the target’s command line reached the hop. With one jump host and something to
protect, -J is replaced by an explicit ProxyCommand running the hop with BatchMode=yes,
PasswordAuthentication=no and KbdInteractiveAuthentication=no. A jump string outside
A-Za-z0-9._@:[]- never goes inside that command, since ssh runs it through a shell. Two or
more hops cannot be constrained one by one, so nothing is wired: ssh asks on the terminal and
sshelf prints one line first. That is the second behaviour change. In tmux mode such a
connection opens in place, because a new window has no terminal to ask on. Decision: D-029.
Worth writing down, because it was the thing I was least sure of: an experiment with two
rootless sshd instances showed that plain -J does not pass StrictHostKeyChecking or
UserKnownHostsFile down to the hop either. The hop has always done its own host-key check
against the user’s real ~/.ssh/known_hosts. So the ProxyCommand is behaviour-equivalent
there and adds no new failure mode. OpenSSH also resolves ~ from the passwd database rather
than $HOME, which is why the hop half of the manual check could not be driven from a
throwaway HOME without writing under the real ~/.ssh, which is not something sshelf does.
The rest, one line each. The transfer screen’s control socket moved from
/tmp/sshelf-mux-<pid>-<seq>.sock into a per-session 0700 directory under
$XDG_RUNTIME_DIR (or the data dir), created with mkdir rather than create_dir_all so an
existing name is refused rather than adopted. Short-lived sftp children (listing, mkdir,
pwd) run under a deadline and an output cap, the worker polls its command channel while one
is running, and closing the screen waits two seconds for an acknowledgement instead of joining
a thread that may be blocked. Single-file downloads land on a .sshelf-part-<ulid> name and are
installed with a link, which fails rather than replacing anything, symlinks included; folders
and uploads keep the listing check, and the remote listing is refreshed right before each send.
A cancel that arrives while a listing is running is
no longer swallowed by it, and one that arrives before the child starts is honoured too (a
failed spawn used to drop it, and with it the transfer parked behind it), and ssh -O check and ssh -O exit are bounded too, so no call the
worker makes is unbounded any more. An upload into a listing the entry cap cut short is refused
rather than guessed at, since a listing check is all an upload has. On a filesystem with no hard
links (exFAT, FAT32, some network mounts) the download install checks the name and renames
instead of throwing the bytes away. atomic_write picks a ULID temp name, creates it
exclusively with its final mode at open(2),
and sweeps siblings older than an hour. Forward stderr logs moved to
<data_dir>/logs/fwd-<id>.log at 0600, and stopping a forward also unlinks the old /tmp
one. The transfer log is opened O_NOFOLLOW and 0600. ensure_dirs no longer chmods a config
directory sshelf did not create, and sshelf doctor gained a config directory permissions
check for exactly that case. Plain CLI output runs host and site fields through one sanitizer
(src/display.rs), the add form and the importers refuse such a name, and --json is
untouched. The 2FA code is masked in the TUI and read with echo off on the command line, in a
Zeroizing buffer either way. Decision: D-030.
The release pipeline. Every uses: is pinned to a 40-character commit SHA with the version
in a trailing comment, and every one was resolved from the API rather than from memory. The
cut-release.yml version input reaches the shell through env: instead of being spliced into
the script, the job is gated on github.ref rather than a run: check, the checkout no longer
persists credentials, and RELEASE_TOKEN is read in one step from a release environment. The
.deb, .rpm and crates.io companions check out workflow_run.head_sha and refuse to publish
if the tag has moved off it; the .deb and .rpm workflows are now a read-only build job plus
a small write-scoped attach job. release.yml is read-only at the top level, publishes build
attestations, and installs dist through a pinned action rather than piping a script from the
network. CI fails if RELOAD_SSH_ALGO is set, and the audit job is
cargo audit --deny unsound --deny yanked with three ignores that each name their reason and
date. dist-workspace.toml gained allow-dirty = ["ci"], so release.yml is hand-maintained
from here: regenerating means dropping that key, running dist generate, and re-applying the
pins.
Did the tests have teeth. Yes, checked rather than assumed. Reverting the classifier to the
old shape-only behaviour makes askpass::tests::classify_matrix,
a_key_host_with_no_identity_files_answers_nothing, and two of the four end-to-end tests in
tests/askpass_plumbing.rs fail, including the one that asserts a Password: prompt gets
nothing from a key host. That file drives a real sshelf process through a stub ssh that asks
the prompts a hostile endpoint would.
For Max, outside this session. Create the GitHub environment named release, put
RELEASE_TOKEN in it, and delete the repository-level copy. This is not a blocker: a job that
names an environment still reads repository secrets, and GitHub creates a missing environment
on the first run, so a cut would work today. It is the point of the change that is missing.
While the token is a repository secret, every workflow in the repo can read it; moving it is
what confines it to the one job that pushes, and a required reviewer on the environment puts a
human in front of that push.
Turn on Dependabot alerts, which the review noted are disabled. File the upstream issue asking
ssh2-config to move its OpenSSH-cloning build script into an explicit developer tool and drop
git2 from its published build dependencies, and decide whether the askpass finding warrants a
GHSA after the tag. Evaluating a smaller SSH config parser is the other half of that and is not
scheduled.
What’s next. v0.15.0, secrets from a password manager (op / bw / rbw / pass). The
secret-kind plumbing added here is what that brief extends.
2026-09-04: punctuation and phrasing pass on the public text
Docs and copy only: no code changed, no version bump, nothing any sentence claims is
different. Everything a stranger can read (README, CHANGELOG, CONTRIBUTING, PRIVACY,
SECURITY, every page under docs/, llms.txt) went through the house-style checker, which
found about 660 hits, most of them em dashes. Each one became a split sentence, a
comma, a pair of parentheses, or “to” for a range, whichever the sentence wanted; the rest
were bolded-label bullets, checkmarks in the milestone table and the old milestone headings,
a few plural “we“s in a one-person project, and a handful of banned words. Where a doc quotes a
string the binary actually prints (the sshelf doctor sample output, the error-message list
in the doctor entry below, 2FA host — connecting here) the quote is left byte-for-byte as
it is, since the docs are supposed to match the program. Cargo.toml’s description lost
its em dash too, so the crates.io blurb reads like the rest.
python3 .claude/skills/human-voice/check.py passes on every public file, mdbook build
renders, and the §6 anchor in packaging.md was re-pointed after its heading changed.
Two things this pass couldn’t reach, both worth a follow-up. The GitHub repo description
(the “About” box, set by hand in the web UI) isn’t in the repo: it should be checked for em
dashes and matched to the new Cargo.toml description. And cut-release.yml writes the
next version heading as ## [x.y.z] — DATE, which would put an em dash straight back into
the CHANGELOG on the next release; the headings here are now ## [x.y.z] (DATE), so that
one line in the workflow has to follow. The binary’s own strings are still full of em
dashes, which is why the sample output in doctor.md still has them; strings and the docs
that quote them should move in the same change, not separately.
2026-09-04: the first two reported bugs
Two issues from users, both display bugs, both fixed for v0.13.1. Nothing in this change bumps the version; the release workflow does that.
Issue #16, the host list showing $USER for a host that inherits its user from a site.
Host::endpoint() resolves nothing, and every list-style caller handed it the raw record, so a
host with no user of its own borrowed the local login name. Connecting was always right,
because those paths already call with_site_defaults. There is now endpoint_in(&sites) on
Host, used by the TUI row, sshelf list, the shell-completion help text, and the import
preview (which resolves against the sites already on file plus the ones the import is about to
create). search_haystack takes the sites too, so searching for the site’s user finds the
hosts that inherit it. That was the same bug one layer down: search matched the raw endpoint.
Issue #15, the remote transfer pane hiding dotfiles. list_remote sent ls -l to sftp,
and sftp’s own ls skips dot-entries unless you pass -a, while the local pane reads the
directory itself and never filtered. A hidden directory on a server was invisible with no way
to walk into it. The listing uses ls -la now, so both sides show the same thing. No toggle:
the reasons are in D-028.
Nothing on disk changed. sshelf list --json still flattens the host as stored, so an
inherited user stays null, with the resolved values visible in the generated command.
endpoint() itself is untouched for callers that want the raw record.
Tests that would have caught both. A TestBackend render case asserting the row reads
deploy@ and never the machine’s own $USER@; a search case for the inherited user; a
list_row case for the plain CLI output and the completion help; a parse_ls_line case for a
dotfile. The transfer e2e now puts a .dotfile and a .hidden-dir/inner.txt on the throwaway
sshd, asserts both are listed, and enters the hidden directory. Also checked by hand: sshelf list and the TUI against a scratch database with a dc site, and a real download of
~/.sshelf-test/.hidden/inner.txt through the transfer screen against the rootless test sshd.
Next: nothing further here. v0.14.0 (secrets from op / bw / rbw / pass) is the next
piece of work.
2026-09-04: README as a landing page, PRIVACY.md, llms.txt
Docs and assets only: no code changed, no version bump, no behavior claim that isn’t already true of v0.13.0.
- README rebuilt. A one-line pitch and badge row (now including the docs site, ratatui, and
the MSRV), the demo clip, install, then a first-person “why I built this”, one short section
per feature with a real capture, a comparison with the tools people weigh sshelf against, and
the never-list written as concrete promises. The hand-drawn ASCII screen is gone; the
captures replace it.
First five minutesand the documentation block survive from the old README because they were already doing their job. - Nine captures, all real.
docs/assets/gainslauncher,transfer,forwards,sites,wizard,doctor,export(PNG),tmux.gif, andlogo.svg;docs/sshelf-readme.gifwas re-recorded. Every one is VHS driving the actual release binary, with the same theme, font size and width, so the page reads as one tool. Nothing is a mockup: the transfer and forward shots move real bytes and bind real ports against a throwaway rootlesssshdon localhost (the same tricksrc/testsupport.rsuses for the e2e tests), and the tmux clip opens a real session. Everything on screen is demo data (RFC-5737 addresses,example.net,mike/deploy), with the machine’s own home, hostname and user kept out of frame. - Two things the recording turned up. VHS can’t send F1 to F12, so the
F3/F4screens are captured with sshelf running inside a scratch tmux session and the key delivered bytmux send-keys. And a host that inherits its site’s bastion really does try the bastion: pointing the tmux clip at aprod-dcmember made the new window die on a DNS failure in under a second: correct behavior, wrong host for a demo. PRIVACY.mdat the repo root: what sshelf reads, writes, runs, and sends, in that order, plus where secrets live and how to check the claims yourself. Linked from the README’s never-list and fromSECURITY.md, which now points at it for the non-attacker half of the picture.docs/llms.txt, in the standard shape: description, a paragraph, and one line per guide page.mdbook buildcopies it to the site root, so it serves at/llms.txt, anddocs/assetslands at/assetsfor the guide pages to reuse later.- The comparison table is sourced, not remembered. Every cell was re-read from the project’s own README (or site) in September 2026, and a cell the source doesn’t state is a dash rather than a guess.
2026-08-21: sshelf doctor, and errors that name the next action
- New
doctor.rs+sshelf doctor. Seven checks, each one line ofok/warn/failplus a single runnable next action when it isn’t ok: the OpenSSH version (fail below 8.4, theSSH_ASKPASS_REQUIREfloor stored secrets ride on; warn if the banner can’t be parsed), the host database (parse errors, and duplicate names or ids, since an id keys both the stored secret and the frecency history), the secret backend, danglingsitereferences, stored secrets whose host is gone,$SSH_AUTH_SOCKwhen any host uses agent auth (also flagged when it points at a socket that no longer exists), and whether the exported ssh_config fragment still matches the database. Exit 0 unless something failed; warnings don’t fail the run, sosshelf doctor && ...is usable in a script. D-027. - Local and read-only. No pings, no test connections, no version lookups:
doctorreports whether sshelf is set up, never whether a host is up. The single write is a throwaway keyring entry (sshelf-doctor-probe, under the realsshelfservice so it exercises the actual code path) that is deleted again, because a backend that reads but can’t write is one that fails the first time a password is saved, and a read-only probe would pass. - Honest about what it can’t check. The
keyringcrate has no portable enumeration, so orphan detection only has teeth in vault mode; the keyring case says it didn’t run rather than reporting a clean bill of health nobody checked. Export staleness is compared by content (the render is deterministic), not by mtime. - Every check is a pure function over inputs the caller gathers, so the whole matrix is
fixture-tested without a keyring, a config dir, or an
sshbinary;secrets.rsgained read-only introspection (backend,probe,stored_ids) andvault.rsanidsaccessor. - Error-message pass. Audited every user-facing string against one rule (name the thing,
the cause, the next action) and rewrote the ones that failed it. Before → after:
save failed: {e}→hosts NOT saved to <path>: {e} — nothing was writtenno host selected→no host under the cursor — clear the filter (esc), or add one (^a)HOME is not set→$HOME is not set — sshelf can't locate ~/.ssh/config to import fromimport failed: {e}→could not import <path>: {e}parsed ok but save failed→<n> host(s) parsed, but <path> could not be written: {e}host saved; secret NOT stored→... — retry with sshelf set-password <name>hosts updated; config NOT saved→... <config path> was not saved: {e} — the new location won't stickhosts NOT written: {e}→could not write <path>: {e} — the hosts file is unchangedcould not start transfer→could not open the transfer screen for <host>: {e}forward up, but not saved→forward up, but not recorded: {e} — F4 will lose it when sshelf restartsfailed to launch ssh→could not launch ssh: {e} — is an OpenSSH client installed and on your PATH?empty password; nothing stored→nothing on stdin — nothing stored; pipe the password in, e.g. printf %s "$PASS" | sshelf set-password <name>a site named 'x' already exists→... — pick another name, or edit that one with F3no host with name or id 'x'→... — run sshelf list to see your hostscould not decrypt vault (wrong passphrase?)→could not decrypt the vault — is $SSHELF_VAULT_PASSPHRASE the passphrase it was created with?- bare
authentication failed/connection refused/connection timed out/could not resolve the hoston a failed forward now each name what to check. No behavior changed inside the pass, only the words.
- Verified end-to-end against isolated config dirs: a healthy setup reports 7 × ok and
exits 0; a deliberately broken one (a
sitethat isn’t defined, a hand-edited stale export fragment,$SSH_AUTH_SOCKunset with agent hosts) reports fail + 2 warn and exits 1, and the remedies it printed were then run:sshelf sites add ...andsshelf exporteach flipped their check to ok and the run back to exit 0. Duplicate names/ids and an unparseablehosts.tomlboth fail with the offending values (a multi-line TOML error is folded onto one line, keeping the line/column and the reason). Vault mode was checked both ways: reachable (including an orphan the keyring case can’t see) and unreadable with a wrong passphrase.~/.sshandhosts.tomlwere byte-identical after every run, and no probe entry was left in the keyring. - 27 new tests (284 pass, 4
#[ignore]d e2e), clippy +fmt --checkclean, no new dependencies. - Docs synced: new
doctor.md(+SUMMARY.md),cli.md,faq.md(relevant answers now end insshelf doctor, plus a new “something isn’t working” entry),structure.md,index.md, README,decisions.md(D-027),cli.md(global flags work on either side of a subcommand), CHANGELOG. Ships in v0.13.0. - Fixed the CLI-routing bug found during v0.12.0 verification. clap’s
args_conflicts_with_subcommandscounts the global flags as top-level args, sosshelf --config FILE set-password webfailed to parse, and, worse, the same flag beforelistmadesshelf --config FILE listread as “connect to a host namedlist”, which would have connected had such a host existed. The setting is gone; the one combination it actually guarded (sshelf <HOST> <subcommand>, which clap parses happily and dispatch would resolve to the subcommand, silently dropping the host) is now rejected by an explicit check with its own message. Every subcommand gained aname()so that message can say which one. Verified across the matrix (both global flags on either side of every subcommand, bare host,-, bare subcommand) with 3 new tests;--help, completions and the man page are unchanged in shape.
2026-08-21: tmux connect modes, transfer multi-select + F7 mkdir
- tmux mode. One config key,
tmux = "off" | "window" | "pane"(defaultoff, also on theF2settings screen as aSpace-cycled toggle). With a mode set and$TMUXpresent,Enterrunstmux new-window/split-windowand sshelf stays up, which is the feature: open four hosts without relaunching the picker. Windows are named after the host (sanitized); panes aren’t (split-windowhas no-n). Everything else is untouched: with the key off, or outside tmux, connect is the same teardown →exec()→ exit-to-shell path as before. Frecency is persisted before the spawn, for the same reason it is persisted beforeexec(). - What may cross the tmux boundary. A tmux window is a child of the tmux server, so it
inherits none of sshelf’s environment, and tmux’s only channel (
new-window -e KEY=VALUE) is the tmux client’s own argv, i.e.ps-visible. So only the askpass wiring rides it (SSH_ASKPASS,SSH_ASKPASS_REQUIRE,SSHELF_ASKPASS, and the opaqueSSHELF_HOST_IDthe helper trades for the real secret); a queued 2FA code, a vault master passphrase, or a tmux older than 3.0 sends the connection back toexec()with a one-line reason printed after the TUI is down. Rationale: D-025. A unit test asserts the code/passphrase variables can never appear in a tmux argv, and the ssh argv is passed as separate arguments after--so tmux execs it directly instead of letting a shell re-split a path with spaces. - Transfer multi-select.
Spacemarks the entry under the cursor,Ctrl-amarks everything the filter shows (again → clear all),Ctrl-ssends the marked set through the existing one-at-a-time worker as a queue, counting2 of 5 name → dest. Marks are positional and die with the listing (cd, refresh, error); sending consumes them. A destination that already holds the name is skipped and the queue continues; a real failure stops the rest and says how many were left.Escgained a rung: cancel → clear marks → clear filter → close. D-026. - Transfer
F7mkdir on both panes (Ctrl-falias for terminals that keep the function keys), throughstd::fs::create_dirlocally andsftp mkdirremotely: one directory, never-p, never adopting an existing name; the new directory lands under the cursor. - Fixed: cancelling a transfer left the screen permanently stuck in “transfer running” (the
worker cancelled silently and never told the UI). It now emits a
Cancelledevent. - Verified end-to-end beyond the tests: inside a scripted tmux session,
windowmode opened a window namedkeyboxrunningsshwith sshelf still live in pane 0 andstate.jsonalready written;panemode split the window instead; the same config with sshelf launched outside tmux exec’d in place (one window,cmd=ssh, no note); a 2FA host printed2FA host — connecting here ...and connected in place; a vault-mode host did the same with its own reason. A passphrase-protected key host logged in through a tmux window, proving the askpass wiring crossed, and the recorded tmux argv held only the four wiring vars, with the passphrase,SSHELF_2FA_CODEandSSHELF_VAULT_PASSPHRASEabsent from every real process argv.~/.sshwas byte-identical throughout (content hashes, not a listing). Transfers were exercised against a real rootlesssshdby two newe2e.rstests driving the actual screen: mark-all → send with a pre-existing duplicate mid-queue (sent 3 of 4 · skipped dup.txt (already there), the duplicate’s contents untouched), the same in the download direction,F7on both sides, and a repeatF7refusing the taken name. - 34 new tests (257 pass, 4
#[ignore]d e2e), clippy +fmt --checkclean, no new dependencies. - Docs synced:
transfer.md(keys, marks, mkdir),configuration.md(thetmuxkey + the F2 field),search-connect.md(a “Connecting inside tmux” section incl. the fallbacks),security.md(the tmux boundary),ssh-command.md(§2a, the tmux argv),architecture.md,structure.md,faq.md(three new answers + a Discussions pointer),index.md, README,decisions.md(D-025, D-026), CHANGELOG. Ships in v0.12.0. - Found, not fixed:
sshelf --config FILE <subcommand>is rejected by clap (args_conflicts_with_subcommandsvs. the global flag), andsshelf --config FILE listis even read as “connect to a host namedlist”. The env var$SSHELF_CONFIGworks. Logged as follow-up rather than folded into this release.
2026-07-27: sshelf import --tailscale, the tailnet as an inventory source
- New
tailscale.rs+ a--tailscaleflag onimport: runs the user’s owntailscale status --jsonand maps every eligible peer to a host: MagicDNS first label →name, the MagicDNS FQDN →hostname(Tailscale IP, IPv4 first, when MagicDNS is off for the tailnet), the tailnet → asite, ACL tags minus thetag:prefix →tags, authagent. Eligibility is one rule (the peer’sDNSNamemust sit under the tailnet’s ownMagicDNSSuffix), which drops Mullvad exit nodes and shared-in nodes without special cases; expired peers are skipped, offline ones are not (an asleep laptop is still a host). Binary resolution:$SSHELF_TAILSCALE_BIN→PATH→ the macOS app bundle, with an error that names all three. - Add-only, in both directions: existing hosts and sites are never updated or deleted
(case-insensitive match), so a re-run converges to “0 added” and a byte-identical
hosts.toml. Nothing tailscale-specific (node IDs, keys) is stored. The no-network posture stands: the CLI runs only from this subcommand, never at startup, on save, on a timer, or from the TUI. Rationale + rejected alternatives: D-024. - Refactor: both import modes now share one tail (
apply_import) that dedupes, creates sites (import::missing_sites), saves and refreshes the export, so the D-023 fragment refreshes after a tailnet import exactly as it does after an ssh-config one. The summary gained a duplicates count and a line per created site. - Verified end-to-end against a stub
tailscalefed the test fixture, with an isolated config dir: 3 hosts + the site land inhosts.toml, counts match (3 parsed, 3 excluded: 2 foreign, 1 expired), a second run adds 0 with an identical file,--dry-runwrites nothing, and an existingNAShost + differently-cased site are reused, not duplicated. A pre-created export fragment refreshed to the imported hosts; without one, nothing was created. Each failure mode gives one message and exit 1: backend not running, unparseable JSON, a bogus$SSHELF_TAILSCALE_BIN.~/.sshwas byte-identical throughout (content hashes, not a directory listing), and the ssh-config import path was re-checked through the shared tail. 18 new tests (213 pass), clippy +fmt --checkclean. - Live tailnet run: on a real 6-peer tailnet with MagicDNS disabled, so the IPv4
fallback took the real path, the binary resolved itself from the macOS app bundle and
imported the 2
peers with valid node keys (
100.xaddresses, site fromCurrentTailnet.Name), excluding 4 with expired keys and this machine (Self); a re-run added 0, andlist --json/print-commandproduced usablesshcommands. Earlier, before login, the same client reportedNeedsLogin→ the friendly “runtailscale up” error, so both live states are covered. - Docs synced:
import.md(now “Importing hosts”, with the mapping table and the opt-in/ no-network posture), cli reference (flag +$SSHELF_TAILSCALE_BIN), FAQ, structure, index, README, CHANGELOG (also collapsed a duplicated0.10.0heading + link line). Ships in v0.11.0 viafeat/import-tailscale. Next:--hcloud/--awsonly if demand shows up.
2026-07-12: sshelf export, an ssh_config Include fragment
- New
export.rs+sshelf export [--stdout]: renders every host (site defaults resolved, name-sorted, deterministic, no timestamps) as ssh_configHostblocks into~/.config/sshelf/ssh_config, and prints the oneIncludeline for the user to add to~/.ssh/config, which sshelf still never writes (it only reads it, to drop the hint once the Include is already present). After that, plain ssh/scp/sftp, rsync, git, and SSH-config pickers (VS Code Remote-SSH) resolve sshelf hosts by name. - Rendering mirrors
build_argsminus sshelf-only plumbing: noStrictHostKeyChecking=accept-new(an askpass necessity, not the user’s choice) and no askpass wiring. Exact-o Key=Valueextras translate to directives; other flags become an in-block comment. Names that can’t be a safeHostpattern (glob/negation/comment/quote chars) are skipped with a comment; all values are control-char-sanitized so nothing can inject extra directives. - Auto-refresh: once the file exists (creating it = opt-in), every hosts save rewrites it,
from TUI
persist_hosts,sshelf add,import,sites add, and the settings hosts-file adopt path. It’s best-effort, so a refresh failure never blocks a save. Deleting the file opts out. - Verified end-to-end:
ssh -G -Fthrough a realIncluderesolves hostname/user/port/ ProxyJump (incl. a site-inherited bastion) and the translated option; auto-refresh observed live onsshelf add. 14 new unit tests (195 pass, clippy clean). Docs synced: new guide pageexport.md, D-023, data-model, structure, cli, import, faq, index, README, CHANGELOG. Targets v0.10.0 viafeat/export.
2026-07-06: docs, user guide split from internals, slim README
- The mdBook site now leads with a Guide for users: install, quickstart, adding/editing
hosts, searching & connecting, file transfer, port forwarding, sites & tags,
passwords/keys/2FA, import, a full CLI reference, configuration, and an FAQ. Content that
previously lived only in the README (CLI flag table, completions setup, config reference) or
inside
ux.mdmoved into these pages;index.mdis a product landing page instead of a doc inventory, andSUMMARY.mdis reorganized into Guide / Understanding sshelf / Development. ux.mdslimmed to UI design notes (visual model, ranking, form design, modality, theming), with stale milestone markers removed; behavior and keybindings are documented in the Guide pages, which the docs-in-sync rule inCONTRIBUTING.mdnow points at.- README cut from ~230 to ~140 lines: pitch, install (secondary channels folded into a
<details>), a “first five minutes” block, and links into the site; added crates.io/CI/ license badges and an explicit no-telemetry / no-account / no-cloud line. Everything removed now lives on the site. - Why: the README had become the user manual because the site didn’t have one, while the site led with contributor docs, and the site is the crates.io homepage, so a real guide is what visitors should land on. Docs-only change; no code touched.
2026-06-23: distribution, crates.io + RPM packaging
- crates.io: new
release-crates.yml(workflow_run after dist’s Release →cargo publish; needs aCARGO_REGISTRY_TOKENsecret, skips cleanly if unset). cargo-dist has no built-in crates.io publish job (publish-jobsonly knowshomebrew/npm/custom), so it’s a companion workflow like the.deb. .rpm: newrelease-rpm.yml+[package.metadata.generate-rpm](x86_64 + aarch64), built as a static musl binary so one rpm runs on any RPM distro regardless of glibc. ssh works identically on RHEL/Fedora, since sshelf only shells out to system ssh/sftp/ps/kill.Cargo.toml: homepage → the Pages docs site, refreshed description + keywords,readme, and anexcludethat trims the published crate 64 → 43 files (drops docs/, .github/, examples/, the gif). Docs synced (packaging.md §4b/§4c, README install, CHANGELOG). Ships in the next release.
2026-06-23: interactive 2FA support
- New: hosts can be flagged
requires_2fa(add/edit form toggle, orsshelf add ... --2fa). Connecting to one shows a code popup before theexec()handoff; the entered one-time code is passed tosshviaSSHELF_2FA_CODEand answered by the askpass helper at the verification prompt, the same channel that supplies a stored password. - Why: a spike confirmed that a stored-secret connect runs with
SSH_ASKPASS_REQUIRE=force, which routes the keyboard-interactive code prompt to the helper with no terminal fallback, so it failed before. The helper now answers a non-secret prompt with the queued code;configure_askpassforce-wires the helper when a secret exists or a code is queued (so key+2FA works too). The CLI direct-connect path prompts for the code on the terminal. - New module
ui/two_factor.rs;Host.requires_2fa(old files loadfalse);exec_connect/configure_askpasstake an optional code (transfer + forward spawners passNone). Manual entry only, no TOTP seeds stored (rejected: same-vault second factor + a dep). Docs synced (decisions D-022, data-model, ux, structure, README, CHANGELOG). Targets v0.8.0 viafeat/two-factor.
2026-06-21: port forwarding, background SSH tunnels (M0 to M4)
- New feature: background port forwards that survive sshelf exiting.
Ctrl-fon a host opens a popup (Local-L/ Remote-R/ Dynamic-DSOCKS);F4opens a manager that lists every active forward and stops any. - Each forward is a detached
ssh -Nprocess in its own process group (stdprocess_group(0), no new dep), tracked by PID inforwards.jsonand reconciled against the OS (ps) on launch, on opening the manager, and each tick while it’s open, with a zombie filter and a PID-reuse guard.ExitOnForwardFailure=yes+ a brief readiness poll surfaces bind/auth errors in the popup. An M0 spike confirmed detached survival (PPID→1, own process group); an#[ignore]e2e drives a real-Lthrough a localhost sshd (bind → traffic → busy-port failure → kill). - New modules
forwards.rs,ui/forward_popup.rs,ui/forwards.rs; the sshd e2e helper was extracted into a sharedtestsupportmodule (transfer e2e now uses it too). Docs synced (decisions D-021, data-modelforwards.json, ux, structure, README, CHANGELOG). 168 tests + 2 e2e; clippy-D warnings+ fmt clean. Targets v0.7.0 viafeat/port-forward→ PR → cut-release.
2026-06-17: sites, docs + feature complete (M5)
- Docs synced:
decisions.mdD-020 (the Sites ADR),data-model.md([[site]]schema + inheritance/override + back-compat),ux.md(grouped/flat list,site:filter, F3 manager, CLI table),structure.md(ui/sites.rs+ model note), README (feature bullet, keys, usage), CHANGELOG[Unreleased]. - Sites is feature-complete: one-per-host grouping with optional inherited SSH defaults
(bastion/user/port/identity); grouped-when-idle / flat-when-filtering list; wizard chooser; F3
manager with rename + delete cascades; and the full CLI. 145 tests + 1 e2e; clippy + fmt clean.
Ships in v0.6.0 via the usual
feat/sitesbranch → PR → cut-release.
2026-06-17: sites, the CLI surface (M4)
sshelf add --site NAME(-s) assigns a site (warns, non-fatally, if it isn’t defined yet).sshelf siteslists defined sites with member counts + their defaults;sshelf sites --jsonfor scripts;sshelf sites add NAME [-u/-p/-J/-i]creates one.sshelf listshows a·site·column;--jsonalready carries thesitefield and acommandthat reflects inheritance. Dynamic completion of site names on--site.- 145 tests (add
--site+sitesparse); clippy + fmt clean. Verified end to end. - Next: docs (D-020, data-model, ux, structure, README, CHANGELOG).
2026-06-17: sites, wizard chooser + F3 manager (M3)
- The add/edit form gains a Site chooser (←/→ over the defined sites + “(none)”); editing a host preselects its site.
- New F3 sites manager (
ui/sites.rs): a list of sites with add / edit / delete and an inline form for each site’s optional defaults (user/port/jump/identity, with name-uniqueness + port validation). Name edits are tracked as renames; on save the app rewrites member hosts’siteand clears any host whose site was deleted (orphans self-heal). Help overlay + list hint updated (F3,site:, and the previously-missing^t). - Tests: wizard chooser + preselect; manager add/edit/rename/delete/duplicate-reject; an app-level F3 rename-cascade + delete-orphan end to end. 143 tests; clippy + fmt clean.
- Next: CLI (
add --site,sites/sites add, list column, completion), then docs.
2026-06-17: sites, grouped/flat host list (M2)
- The host list now groups by site when idle (
── {site} ({n}) ──section headers, sites alphabetical,(no site)last) and shows a flat ranked list with a dim·site·column while filtering.recomputebuilds a groupedorderwhen the query is empty (group_order);orderstill holds host indices only, so selection/navigation are unchanged, and the renderer maps the selected host past the non-selectable headers to theListStateindex. - Tests:
group_ordersectioning (case-insensitive,(no site)last); render checks for the grouped headers + the filtered site column. 135 tests; clippy + fmt clean. - Next: the wizard site chooser + F3 sites manager (M3), then CLI (M4).
2026-06-17: sites, model + inheritance + search (M1)
- New Site concept: a one-per-host grouping that may carry optional shared SSH defaults
(user/port/jump/identity) member hosts inherit at connect time. Bare site = pure grouping;
per-host fields always override; auth stays per-host. Distinct from many-valued
tags. model.rs:Sitestruct +Host.site: Option<String>(by name) +HostsFile.sites([[site]], sites-first; noformat_versionbump, so old files load unchanged). Inheritance viaHost::with_site_defaults(&[Site])(clone, fill only unset fields, id preserved; unknown site name degrades to plain grouping) +find_site(case-insensitive).search_haystackincludes the site.search.rs:parse_querynow also yields an optionalsite:NAMEtoken;rankfilters by it.- Threaded resolution into every Host→ssh-args boundary: TUI connect/yank/transfer, CLI
connect/
-/print-command/list --jsoncommand.App.sitesloaded + persisted (and it follows an F2 hosts-file move). Verified end-to-end viaprint-command+list --json. - 132 tests (model inheritance/degradation,
site:filter, store round-trip + pre-sites back-compat); clippy + fmt clean. No UI yet. - Next: the grouped/flat list (M2), then the wizard chooser + F3 sites manager (M3), CLI (M4).
2026-06-16: transfer, --transfer-log diagnostics
- Added a transfer debug log:
sshelf --transfer-log <FILE>(or$SSHELF_TRANSFER_LOG) appends everyssh/sftpcommand the worker runs plus its full stderr toFILE, so a failed transfer can be inspected after the fact (the status line still shows the one-line cause). No secrets are logged: the password reachessshviaSSH_ASKPASS, never argv. The e2e test asserts the log captures the master +get/putcommands. Docs: README,ux.md(CLI table + transfer section),security.md.
2026-06-16: transfer, use sftp (not scp) for the copy itself
- Bug found in local testing: transferring a filename with spaces failed
(
scp: failed to upload ... to '/...). OpenSSH 9+scpspeaks the SFTP protocol and takes the remote path literally, so the shell-quoting legacyscpneeded became literal quotes in the name. Plain names slipped through because they aren’t quoted. - Fixed by running transfers through
sftpget/putover the same master used for listing.sftpquotes via its own command parser consistently across OpenSSH versions, so the version-dependentscpquoting trap is gone. Removedscp_args/remote_spec; added atransfer_batchunit test and a spaces regression to the e2e test.
2026-06-16: transfer screen, transport core + worker
- Started the dual-pane SFTP/SCP transfer screen. Settled the transport (see
decisions.mdD-019): move files over the systemsftp/scpriding a singlesshControlMaster, so keys/agent/ProxyJump and the stored keyring/vault secret are reused unchanged and password hosts work with no PTY. A spike against a local sshd confirmedSSH_ASKPASSopens the master and thatsftp/scpride it (put/get + recursive). - Landed the tested core in
src/transfer/mod.rs: the master/sftp/scpargv builders, theuser@hosttarget + shell-quoted remote-path spec, the worker↔UI message protocol, and progress math. - Added the worker thread + ControlMaster lifecycle (
src/transfer/worker.rs): it opens the master (reusingssh::configure_askpass, nowpub(crate)), polls it ready, lists remote dirs by parsingsftp ls -l, runsscptransfers with throttled progress + mid-flight cancel, and tears the master + control socket down on stop via RAII. 101 tests; clippy + fmt clean. No UI yet; the live end-to-end run lands with the engine milestone. - Added
transfer/pane.rs, one side’s state: fuzzy filter + selection + navigation reused from the key-picker browser, a synthetic..entry,ls -F-style dir/@-symlink labels with control-char stripping, and a local-directory reader. Kept source-agnostic rather than behind aDirSourcetrait (a synchronous remotelist()would block the very UI loop the worker keeps responsive); the screen feeds local entries viastd::fsand remote ones via the worker. 109 tests; clippy + fmt clean. - Wired the screen end to end:
transfer/screen.rs(two panes over one session, where local nav is synchronous, remote nav requests go via the worker, and events are drained each tick) andui/transfer.rs(two panes, progress/status line, hint bar;TestBackend-snapshotted via a borrowed view, and a “terminal too small” clamp).Ctrl-ton the list opens it (Outcome::Transfer); the event loop polls + drains while it’s open and tears the worker down on close (RAII). Keys:Tabswitch ·→/Enteropen ·Ctrl-ssend file/folder ·←/Backspaceup ·Esccancel/ clear/close. Docs:ux.mdtransfer section + keybinding. 113 tests; clippy + fmt clean. - Validated the transport end to end against a throwaway localhost
sshd(transfer/e2e.rs,#[ignore]d, run withcargo test -- --ignored): the master opens,sftppwd/lsparse, single-file download + upload (contents verified), and recursive directory download all pass. - Robustness + docs pass: a same-named destination is skipped rather than clobbered;
README gains a feature bullet + the
^tkey,security.mdcovers the transfer network path, andstructure.mdmaps the new modules. Added master-command tests for ProxyJump + password hosts, since the auth itself reusesbuild_args/configure_askpass(already tested), and the M0 spike provedSSH_ASKPASSopens the master, so a password target and key/agent jumps work; a fully automated password-auth transfer E2E needs a PAM/Docker sshd (the rootless test server is key-auth only) and is a CI-with-Docker follow-up. - The transfer screen is functionally complete: dual-pane browse + fuzzy on both sides, single-file and recursive folder transfer in both directions over one multiplexed master, live progress, cancel, and overwrite-skip. 116 unit tests + 1 e2e; clippy + fmt clean.
2026-06-13: CLI, non-interactive add, list –json, dynamic completion, reconnect-last
sshelf addgained flags for a fully non-interactive add (scripts/dotfiles):NAME+-H/--hostnamerequired;-u/-p/-a/-i/-J/-t/--extra/--password-stdin. Auth is inferred (keyfrom--identity,passwordfrom--password-stdin, elseagent).--extraallows hyphen-leading values;--password-stdinkeeps the secret out of argv. Baresshelf addstill opens the TUI form. Duplicate names are refused. (AddArgs::into_hostis pure/tested.)sshelf list --jsonemits each selected host’s fields plus its generatedcommand, always valid JSON (even empty), the stable surface for integrations.- Dynamic shell completion of host names via
clap_complete(unstable-dynamic):CompleteEnvinmain,ArgValueCandidateson the<host>args of direct-connect /print-command/set-password;host_name_candidatesreadshosts.tomlside-effect-free. Enable withsource <(COMPLETE=<shell> sshelf). sshelf -reconnects to the most-recently-used host (last_used_idover the frecency state); the CLI connect path was factored into a sharedconnect().- 99 tests; clippy + fmt clean; verified end-to-end (add/list –json/password-stdin/completion).
- Docs: README (usage + an “Adding hosts from the CLI” flag table + a “Shell completions”
section) and the
docs/ux.mdCLI table.
2026-06-12: CLI, print generated ssh command
- Added
sshelf print-command <host>: prints the same shell-quotedssh ...command as the TUICtrl-yyank action, without connecting or updating frecency. Useful for scripts, wrappers, and review before running a connection. - Fixed generated command strings to expand identity-file
~before shell-quoting, so yanked or printed commands remain copy-paste runnable. - Docs synced: README usage,
docs/ux.mdCLI table, anddocs/ssh-command.mdbuilder note.
2026-06-07: pre-launch hardening
sshelf addnow opens the TUI with the add form ready (app::run_add); it was a placeholder message. Empty-list hint and internal comments cleaned of milestone references.- Vault env hygiene:
configure_askpassstripsSSHELF_VAULT_PASSPHRASEfrom the child env when no stored secret is wired; kept (and now documented) for vault-mode askpass, which reads it as ssh’s child. Two new env-wiring tests (ssh.rs). - SECURITY.md: concrete private-reporting channels (GitHub advisories + email) replace the
placeholder note; added the vault-mode env-inheritance tradeoff (mirrored in
docs/security.md+docs/ssh-command.md). - CHANGELOG.md added (backfilled 0.1.0 / 0.2.0); README now states the no-network posture
(no telemetry / account / network calls) and documents
sshelf add. - CI: new
cargo audit(RustSec) and MSRV-1.88 check jobs.
2026-06-07: release v0.2.0
- Cut v0.2.0: ships the
sshelf <host>direct-connect andsshelf list <query>filter (below). Taggingv0.2.0republishes brew / shell installer /.debvia dist.
2026-06-07: CLI, direct connect + list filter
sshelf <host>connects straight to a saved host by name/id, skipping the TUI (reuses the TUI connect path: frecency recorded beforeexec, askpass wired only when a secret exists). A miss suggests close names. Clap routes viaargs_conflicts_with_subcommands, so subcommand names win.sshelf list [query]filters with the same syntax as the TUI search box (search::rank): fuzzy text and/ortag:NAME. Plainsshelf listis unchanged.- 88 tests (added clap-routing + host-resolution); clippy + fmt clean. Docs: README usage + a brew
completion-reload note; new
docs/ux.mdCLI section.
2026-06-07: README demo GIF
- Added an animated demo to the top of the README (
docs/sshelf-readme.gif): fuzzy-search → yank the generatedsshcommand.
2026-06-06: v0.1.0 released
- First public release is live: dist’s
Releaseworkflow built all four targets, created the GitHub Release (tarballs + shell installer), and published the Homebrew formula;release-debattached the amd64/arm64.debs. All jobs green. - README Install section rewritten for the real channels (Homebrew, shell installer,
.deb, from source).docs/packaging.mdsynced to the shipped setup:dist-workspace.tomlconfig,workflow_runsequencing of the.debjob, and theHOMEBREW_TAP_TOKENprerequisite.
2026-06-06: release pipeline, dist (cargo-dist) wired up
dist init: shell + Homebrew installers, 4 Unix targets (mac + linux × x86_64/arm64),install-updater = false. Addedrelease.yml,dist-workspace.toml, and[profile.dist].- Dropped the
x86_64-pc-windows-msvctarget dist added by default, since sshelf is Unix-only (the connect path usesexec()), so a Windows build can’t compile. - Reworked
release-deb.ymlto run viaworkflow_runafter the distReleaseworkflow finishes, attaching the.debs to the release dist creates, which avoids both workflows racing to create the same release. - Before tagging: create the
max-rh/homebrew-taprepo + aHOMEBREW_TAP_TOKENsecret (PAT) so the Homebrew formula can be published.
2026-06-06: CI, fix the push trigger
ci.ymllistened onmain, but the default branch ismaster, so direct pushes never ran CI. Now triggers on[master, main].
2026-06-06: funding notes, trim public meta-commentary
- Removed the BTC-address caveat from the README Support section (the donate badge + address stay).
- Trimmed the
.github/FUNDING.ymlcomment down to the functional config.
2026-06-06: docs, contributor guide + naming polish
- Adopted
CONTRIBUTING.mdas the contributor guide (GitHub-conventional name) and refreshed its cross-references indocs/{index,structure,decisions}.md. - Standardized the “docs-in-sync rule” naming across the docs.
- No code changes.
2026-06-05: post-v1, browser fuzzy search, dynamic wizard width, settings screen
- File browser fuzzy search: type to filter the listing (nucleo);
Backspaceedits the filter (else up-dir),Escclears it (else cancels). Sharedui::highlightbetween the host list and browser. - Dynamic wizard width: the add/edit form sizes to the terminal (clamped 56 to 100), fixing
placeholder truncation; longest placeholders trimmed; placeholders now read
optional ·/required ·. - Settings screen (
F2) +ui/settings.rs: edit the hosts-file location (default shown;~expanded), config-file path shown read-only. Newhosts_fileconfig key;--configflag +$SSHELF_CONFIGenv (plumbed via env so subcommands + askpass-irrelevant paths stay uniform);Config::save/hosts_path;App.hosts_paththreaded through list/import/set-password. - Fix: the hosts-file relocate could overwrite an existing target with the (possibly empty) in-memory hosts → now it adopts an existing file and only writes through to a new path, committing config only on success. Two app-level tests cover both branches.
- Help overlay height bumped (the F2 line was clipping). 84 tests; clippy + fmt clean.
- Deviation to confirm: “custom config file” is via
--config/env (shown read-only in settings), not editable in the wizard, which is the bootstrap-correct interpretation. - Snapshots:
target/{wizard,browse,settings}-snapshot.txt.
2026-06-05: post-v1, .pem keys + in-TUI file browser
Follow-up to the wizard work (user requests):
.pemand keyless keys are detected:scan_keysincludes any private key by sniffing aPRIVATE KEYheader rather than only<name>.pubpairs (AWS keys show up).- File browser (
ui/browse.rs): the Key field opens it withEnter(←/→still cycles recent~/.sshkeys); navigate dirs and pick a key anywhere without typing a path. A browsed path is stored as the host’s identity even outside~/.ssh. - Placeholders now mark fields
optional ·/required ·. The Key field’s hint becomes “←/→ recent keys · ↵ browse files” when focused. - 75 tests (incl.
scan_keysagainst a temp dir with a.pem, browser nav, Enter→browse); clippy + fmt clean. Snapshots:target/{wizard,browse}-snapshot.txt. - Acceptance gate: the browser + Enter→browse→pick flow is
TestBackend-only; a real-TTY run (open the Key field, browse to a.pem, pick, save, connect) is still pending, folded into gate #2 below.
2026-06-05: post-v1, auth-aware wizard, key picker, key-passphrase auto-supply
User-requested wizard improvements:
- Every field shows a dim placeholder explaining it.
- The form is auth-aware, so only relevant fields show: key → Key picker + optional Key passphrase; password → Password; agent → neither.
- Key picker cycles private keys discovered under
~/.ssh(files with a.pubsibling). - Key passphrase (optional) is stored as the host secret; askpass now answers passphrase prompts too, and connect wires askpass whenever a stored secret exists (password OR passphrase).
Hardening review, confirmed and fixed:
- the “password NOT stored” message → “secret NOT stored” (applies to key passphrases too);
is_secret_prompttightened to OpenSSH prompt shapes (ends-withpassword:/ containspassphrase for) so a keyboard-interactive server can’t phish the stored secret;discover_ssh_keysno longer uses lossy UTF-8 conversion (won’t miss/corrupt keys);- editing a multi-key host no longer drops the extra identity files.
- Dismissed false alarms: env-clearing already unconditional, the keyring check is fail-closed, multi-key-passphrase is out of scope. Skipped 2 lows (wide-char mask cosmetics; the already documented macOS double-Keychain-prompt on unsigned builds).
- 66 tests; clippy
-D warnings+cargo fmt --checkclean.
2026-06-05: M8, OSS readiness
- Linux verified for real (Docker
rust:latest): build + all 63 tests pass. The first Linux build caught a bug:sync-secret-servicepulled the Clibdbus-sys(needslibdbus-1-dev). Switched to pure-Rustasync-secret-service+crypto-rust+async-io→ no C/OpenSSL/tokio build deps. (Closes acceptance gate #3.) README.md,SECURITY.md(threat model + macOS-signing note),LICENSE-MIT+LICENSE-APACHE(dual), and.github/workflows/ci.yml(fmt + clippy + build + test on macOS & Linux, plus a headless-vault job that stores/retrieves via the age vault withDBUS_SESSION_BUS_ADDRESSunset, verified locally).cargo fmtapplied repo-wide so the CI format check passes.- 63 tests; clippy
-D warningsclean on macOS and Linux.
2026-06-05: M7, read-only import from ~/.ssh/config
import.rs:ssh2-config 0.7.1parse (ALLOW_UNKNOWN_FIELDS) →Hostmapping (name, hostname, user, port, identity files; the parser expands~to an absolute path). Skips wildcard patterns; warns aboutMatch/Include/ProxyJump(unsupported).Ctrl-oin the TUI imports all new (non-duplicate-by-name) hosts;sshelf import [--dry-run]does the same from the CLI. Never writes~/.ssh/config.- Verified against the real
~/.ssh/config: parsed 4 hosts read-only (mtime unchanged), correct mapping,--dry-runwrote nothing. - v1 deviation: no in-flight per-host selection UI; it imports all new hosts, then you
curate with edit/delete (recorded in
docs/ux.md). - 63 tests pass; clippy
-D warningsclean.
2026-06-06: distribution, dist + .deb + clap completions/man (chosen stack)
Picked the channels (GitHub user max-rh): dist/cargo-dist for Homebrew + tarballs +
shell installer, cargo-deb for Debian/Ubuntu, clap for completions/man, no crates.io.
- Code: added
sshelf completions <shell>andsshelf mansubcommands (clap_complete/clap_mangenviaCli::command(), no build.rs). Verified bash/zsh/fish + roff output. - Packaging:
[package.metadata.deb]inCargo.toml(dependsopenssh-client, recommendsgnome-keyring, ships completions + man);.github/workflows/release-deb.ymlbuilds amd64 (ubuntu-22.04) + arm64 (ubuntu-24.04-arm) natively and attaches.debs to thev*Release (upserts alongside dist’srelease.yml). docs/packaging.mdrewritten around this stack (multi-arch x86+arm, distinitchoices, the deb companion, the macOS signing/Keychain note, manual Homebrew formula + APT repo in an appendix). dist’srelease.ymlitself is generated bydist init(documented).- §6 reframed: no paid Apple Developer Program needed, since a CLI via Homebrew runs unsigned
(Homebrew doesn’t quarantine formulae; arm64 just needs the free auto ad-hoc signature).
Paid Developer ID/notarization is optional (only removes Gatekeeper friction for direct
.tar.gzdownloads). Vault stays the free Keychain fallback. - Chose option 3 (free ad-hoc signing): verified on this Intel Mac that a default build is
“not signed at all” and
codesign --sign - --force→Signature=adhoc; documented the exact step + where it slots into dist’srelease.yml(§6). No paid Apple program. - Email: advised an alias (public in
.deb/repo);authorsmade optional. License: keep dual MIT OR Apache-2.0. Funding: BTC only for now (GitHub Sponsors needs a payout setup), via the README Support section +.github/FUNDING.yml(custom→README). - Pre-public-push scan: clean (no real keys/personal email/host IPs). Swapped a coincidental LAN
IP in a test for the RFC5737 doc range; set
Cargo.tomlrepository/homepage to max-rh/sshelf. - 84 tests; clippy + fmt clean. BTC address filled in. Ready for the initial public push
(branch
master).
⚠ Unverified paths (acceptance gates before “done”)
These are verified by unit tests but NOT yet exercised on a real path; treat as manual acceptance gates (a sandbox can’t cover them):
- macOS OS-keyring path: only the vault secret path (
SSHELF_VAULT_PASSPHRASE) is verified end-to-end. The default macOS path (no env var → Keychain) is unrun; an unsigned dev build’s re-exec’d askpass child may hit a Keychain access prompt per connect (ACLs are keyed to code signature). Run from a real macOS GUI session; until then, the vault is the recommended setup and is what’s been proven. - The full in-TUI connect chain has never run as one piece. For a password host it is:
real TTY →
exec_connect(which setsSSH_ASKPASS/SSHELF_*env) →exec(ssh)→ ssh re-execssshelf(askpass mode) as a child, which resolves paths + fetches the secret. The M5 E2E hand-set the env and calledsshdirectly; it did not go throughexec_connect; andTestBackenddoesn’t touch raw mode / alt-screen. Acceptance test: connect to a real password host from inside the TUI (rather thansshalone), and exercise the key file browser (open the Key field →Enter→ browse to a.pem, type-to-filter → pick → save → connect) and the F2 settings relocate (change the hosts file, confirm it adopts/relocates correctly). If macOS Keychain prompts on every connect for the unsigned dev build, that’s expected → use the vault or a signed build. Linux build, closed (M8): built + tested in Dockerrust:latest(63 tests pass) with the pure-Rustasync-secret-servicebackend; CI now builds/tests Linux + a headlessDBUS_SESSION_BUS_ADDRESS-unset vault job. (First real CI run still pending.)
2026-06-05: M6, tags, config, theme, frecency wiring
- Tag filtering (the explicitly-chosen v1 feature):
tag:NAMEtokens in the query AND every tag (case-insensitive, exact); remaining words fuzzy-match. Combine freely (tag:prod web). Help overlay + hint bar updated. default_sortwired into the TUI (was list-CLI only): empty query honors frecency-or-name from config.config.tomlmade real: a commented default is written on first run (TUI orlist), withdecay_rate,default_sort, and a newaccentcolor (themes the UI via a one-time color cell). Default-template parse is tested.- Deleted dead
error.rs(committed fully toanyhow). - 59 tests pass; clippy
-D warningsclean. Verified default config write + tag filter.
2026-06-05: M5, secrets + password auto-supply (verified end-to-end)
vault.rs: age-encrypted (age 0.10.1, scrypt + ChaCha20-Poly1305)host_id → passwordmap; store/get/delete + atomic writes.secrets.rs: routes to the OS keyring by default, or the vault whenSSHELF_VAULT_PASSPHRASEis set (deterministic, headless/CI-friendly).keyring 3.6.3with per-target backends (apple-native / sync-secret-service / windows-native).askpass.rs: headlessSSH_ASKPASSmode that inspectsargv[1], answers only password prompts (fetches bySSHELF_HOST_ID), declines everything else with exit 1.ssh.rs:configure_askpasssetsSSH_ASKPASS/REQUIRE=force/SSHELF_ASKPASS/SSHELF_HOST_IDfor password hosts only, clearing inherited askpass otherwise.- Wizard gained a masked Password field; save stores the secret; delete removes it.
- New
sshelf set-password <name|id>CLI (reads stdin) for headless/scripted provisioning. - End-to-end verified with the real binary against the live password sshd:
set-password→ vault; askpass returns the secret for a password prompt and declines a host-key prompt (exit 1); and a fullssh(SSH_ASKPASS=sshelf) logged in with no prompt (PW_AUTOSUPPLY_OK). - 54 tests pass (vault round-trip, prompt classification, wizard password capture); clippy clean.
2026-06-05: M4, add / edit / delete
ui/widgets.rs: hand-rolled single-lineTextField(insert/backspace/cursor moves).ui/wizard.rs: full-screen add/edit form (9 focusable fields: name, hostname, user, port, auth toggle, identity, jump hosts, tags, extra args) with inline validation; returnsWizardOutcome {Continue, Cancel, Save(Host)}. Chose a single-screen form over a paged wizard (simpler/editable);ux.mdupdated.app.rs:Ctrl-aadd,Ctrl-eedit (prefilled),Ctrl-ddelete (confirm popup). Save upserts by id and writeshosts.tomlatomically; delete also drops the frecency entry.- Verified add-persists-to-disk and delete via tests (incl. reload-from-disk). Wizard render
snapshot at
target/wizard-snapshot.txt. - 46 tests pass; clippy
-D warningsclean.
2026-06-05: M3, connect via exec() + yank
ssh.rs:build_args(-iper key with~expansion,-ponly if non-22,-Jcomma chain,-o StrictHostKeyChecking=accept-new, shlex-split extra args,user@host);command_string(readable, tilde-preserved, for yank);exec_connectviaCommandExt::exec(unix process replacement);copy_to_clipboard(arboard, best-effort).app.rs:Enter→Outcome::Connect,Ctrl-y→Outcome::Yank. Connect defers to afterratatui::restore():runrecords frecency + saves state, thenexecs ssh (clean TTY). Panic-safety is handled by ratatui’sinit()panic hook (no separate RAII guard needed).- Added
shlex 2.0.1,arboard 3.x(no-default-features, text-only). - Verified: recreated the spike sshd with a public key and connected with the exact
build_argsflag set (-i ... -p 2222 -o StrictHostKeyChecking=accept-new tester@127.0.0.1) →CONNECT_OK. (Interactive TUI→exec is TTY-only; argv logic is unit-tested and the live connection is proven here.) - 33 tests pass; clippy
-D warningsclean (collapsed nested ifs into 1.88 let-chains).
2026-06-05: M2, core TUI (list + fuzzy search)
Added ratatui 0.30.0 + nucleo-matcher 0.3.1. The atuin-style launcher renders: search box
(with matched/total in the title), highlighted fuzzy list, contextual hint bar, F1 help overlay.
search.rs: nucleo fuzzy ranking; empty query → frecency order, else score desc with frecency tiebreak;match_indicesfor per-char highlight.app.rs:App+ pureon_keyreturningOutcome {Continue, Quit, Connect(idx)}, plus the sync event loop usingratatui::init()/restore(). Single-mode search → Ctrl-based actions (resolved the plain-letter-vs-typing conflict;ux.mdupdated).ui/{mod,list,help}.rs: rendering as pure fns of&App, verified withTestBackend(no TTY). ASCII snapshot written totarget/tui-snapshot.txt.- 25 tests pass; clippy
-D warningsclean. Connect currently shows a placeholder status; the realexec()handoff is M3.
2026-06-05: M1, scaffold + persistence
Crate sshelf (edition 2024, rust-version = 1.88, license MIT OR Apache-2.0) builds clean
with clippy -D warnings; 12 unit tests pass.
- Deps resolved:
serde 1.0.228,toml 1.1.2,serde_json 1.0.150,etcetera 0.11.0,clap 4.6.1,thiserror 2.0.18,anyhow 1.0.102,ulid 1.2.1. - Modules:
model(Host/AuthMethod/HostsFile + ULID ids),paths(XDG viaetcetera::Xdg→~/.config/sshelfconfirmed on macOS),store(TOML load/save + atomic temp+rename),state(frecency:use_count/last_used,score = count·e^(−decay·days)),config(decay_rate, default_sort),error(typedSshelfError). main: clap CLI (list/add/import), askpass-via-env dispatch stub,listworks and sorts by frecency. Verified end-to-end againstexamples/hosts.sample.toml.- Forward-declared API (
save_hosts,atomic_write,state::save/record_use,Host::new,find,search_haystack) carries#[allow(dead_code)]+ a milestone note; each allow is removed as the function is wired up. - Note: cargo defaulted to edition 2024; updated the project guide accordingly.
2026-06-05: M0, askpass mechanism validated (spike)
Empirically validated the password auto-supply design against a real password-auth sshd
(Docker lscr.io/linuxserver/openssh-server, OpenSSH 10.2 client) on macOS. Also bumped the
toolchain: Rust 1.74 → 1.96.0 via rustup update (clears the ratatui 0.30 MSRV gate).
- Test 1 (success):
SSH_ASKPASS=helper SSH_ASKPASS_REQUIRE=force+PreferredAuthentications=passwordStrictHostKeyChecking=accept-new→ logged in, exit 0. ConfirmsSSH_ASKPASSsatisfies interactivePasswordAuthentication(as well as key passphrases). The helper receivedargv[1] = "tester@127.0.0.1's password: ".
- Test 2 (host-key routing): with
StrictHostKeyChecking=ask+ fresh known_hosts, ssh sent the helper the host-key prompt ("...Are you sure you want to continue connecting (yes/no/[fingerprint])?"), and a naive “always return the password” helper caused an infinite loop on"Please type 'yes', 'no' or the fingerprint:". - Conclusions (both already in the design): the helper must inspect
argv[1]and answer only password prompts (exit non-zero otherwise), and sshelf must pass-o StrictHostKeyChecking=accept-newso the host-key prompt never reaches it. See ssh-command.md §3. - Spike container kept running (
sshelf-spike, host port 2222) for reuse in M5.
2026-06-05: documentation foundation
- Created the project guide (the docs-in-sync rule + the hard project invariants).
- Created the
docs/tree:index,progress,architecture,structure,data-model,ssh-command,ux,decisions,security, all seeded from the project plan. - No Rust code yet. Toolchain still on Rust 1.74, so
rustup updateto 1.88+ before M1. - Next: M0 askpass spike (validate password auto-supply on macOS + Linux before building on it).
Milestones
Tracking against the project plan. Status is one of not started, in progress, or done.
| # | Milestone | Status |
|---|---|---|
| n/a | Docs foundation (project guide + docs/) | done |
| M0 | Spike SSH_ASKPASS password mechanism | done (macOS; Linux pending in CI) |
| M1 | Scaffold crate + persistence (paths/model/store, clap, licenses) | done |
| M2 | Core TUI: list + fuzzy search + highlight + hint bar | done |
| M3 | Connect via exec() handoff (key/agent hosts) + yank | done |
| M4 | Add/Edit/Delete wizard (+ quick-add) | done |
| M5 | Secrets (keyring + age vault) + password auto-supply (askpass) | done |
| M6 | Polish: frecency tuning, tags, config, help, theme | done |
| M7 | Read-only import from ~/.ssh/config | done |
| M8 | OSS readiness: README, SECURITY, CI, licenses | done |
The full milestone detail lives in the project plan.