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.