Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

FileLocation (default)OwnerPurpose
hosts.toml~/.config/sshelf/hosts.tomluserThe host database. Human-editable.
config.toml~/.config/sshelf/config.tomluserPreferences (theme, decay_rate, sort, keybinds).
state.json~/.local/share/sshelf/state.jsonappFrecency counters, keyed by host id. Churns; not for hand-editing.
forwards.json~/.local/share/sshelf/forwards.jsonappLedger of active background port-forwards (PIDs). Reconciled against the OS on launch. Mode 0600.
ssh_config~/.config/sshelf/ssh_configappExported 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.ageappFallback encrypted secret store (only when no OS keyring). Mode 0600.
forward logs~/.local/share/sshelf/logs/fwd-<id>.logappOne 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.sockappThe 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>appOne 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 / tags are Vec<String> (empty = absent).
  • format_version lets sshelf migrate the schema later without breaking older files. Adding [[site]] and host.site needed no bump: old files load with sites = [] / site = None.
  • requires_2fa marks a host whose login needs an interactive verification code; connect collects it and passes it to ssh via the transient SSHELF_2FA_CODE env var (never stored on disk). See decisions.md D-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 in hosts.toml keeps its history).
  • Updated before exec() on connect: use_count += 1, last_used = now.
  • Kept separate from hosts.toml so the user-owned host file stays stable and diff-friendly.
  • Score: use_count * exp(-decay_rate * days_since_last_used) (decay_rate default 0.2). See ux.md for 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 running ssh processes 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. See decisions.md D-021.
  • spec omits empty fields (bind defaults to 127.0.0.1, target_host to localhost); Dynamic forwards carry only listen_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.