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.