Initial commit: dial — interactive SSH host picker

A fast, read-only ~/.ssh/config picker that exec's the system ssh client.
Includes: static high-contrast picker with recency/frequency/name sort and
tag grouping, `dial keygen` (native ed25519/RSA + optional config entry), and
an optional offline YubiKey launch gate with one-time recovery codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:18:23 +10:00
commit 8af44dfcd2
31 changed files with 4219 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
# dial — project report
A status report on the `dial` CLI, written to be self-contained so it can be
pasted into a fresh chat for brainstorming. No access to the codebase is needed
to follow it.
## What dial is
`dial` is a fast, interactive command-line SSH host picker. It reads
`~/.ssh/config` (strictly read-only), shows a clean static list of hosts, and
on selection `exec`s the real system `ssh` client into the chosen host — so the
terminal/tty, `ProxyJump`, agent forwarding, etc. all "just work" because dial
gets out of the way (it does not stay a parent process wrapping ssh).
It's written in **Go**, ships as a **single static binary** per platform, and
targets macOS (Apple Silicon + Intel), Linux (amd64 + arm64), and Windows.
## Design decisions already made
These are settled and built:
- **Language: Go.** Chosen over Python/Bash for true single-binary
cross-platform distribution and fast cold start.
- **UI style: static, numbered, high-contrast list — NOT fuzzy-filter-as-you-
type.** This was a hard requirement: legible in low light, generous spacing,
large clear text, high contrast. A `/` key opens an on-demand search/filter,
but the resting state is always the clean static list.
- **TUI library:** `charmbracelet/bubbletea` + `lipgloss`.
- **Config parsing:** hand-written parser (not a library) so it can follow
`Include` directives *and* support a custom `# tag: prod, riedel` comment
convention above `Host` blocks for grouping/filtering.
- **Connect = process handover.** Unix uses `syscall.Exec` (replaces the
process); Windows runs ssh.exe as a child with inherited stdio and exits with
its status.
- **App data lives in `~/.ssh/.dial/`** so it rides along on the user's existing
Syncthing sync — no new sync config. Two files: `settings.json` (prefs +
hashed gate data) and `data.json` (recents, frequency, favourites). Writes
are atomic; reads tolerate a partial/corrupt file mid-sync by falling back to
defaults; concurrent cross-machine writes are last-write-wins.
- **YubiKey gate (optional):** offline HMAC-SHA1 challenge-response by shelling
out to Yubico's `ykman` CLI (slot 2 default). This is the one feature that
breaks "zero external deps" — `ykman` must be installed separately — a
deliberate trade to lean on Yubico's own tool. dial checks for `ykman` on
PATH and errors clearly if missing.
- **Gate timing: required on every launch** (chosen over an idle-timeout).
- **Backup recovery code:** 6 groups of 5 base32 chars (~150 bits), shown in
the terminal exactly once at enable time, stored only as a salted hash.
Single-use: after it's used to get in, it's invalidated and a fresh one is
issued (shown once).
- **Nothing secret is ever stored or synced.** In challenge-response mode the
YubiKey secret never leaves the key; dial persists only a random (non-
secret) challenge plus a salted hash of the expected response.
- **Selection is decoupled from the connect action** so a future SFTP mode can
reuse the picker without a rewrite. There's already a stubbed `SFTP` command
alongside `SSH`.
## Architecture (package layout)
- `cmd/dial` — entrypoint + CLI subcommands (`yubikey enable/disable/status/
recovery`, `version`, `help`) and the pre-TUI gate flow.
- `internal/sshconf` — read-only config parser: Include following, tag comments,
extracts Hostname/User/Port/ProxyJump.
- `internal/store` — the `~/.ssh/.dial/` persistence (atomic, sync-tolerant).
- `internal/picker` — the Bubbletea TUI (static list, on-demand filter, sort
toggle, favourites, preview pane).
- `internal/connect` — the exec-into-ssh handover (per-OS files).
- `internal/yubikey` — ykman challenge-response + recovery code logic.
- `internal/theme` — high-contrast, low-light-friendly styling (adapts to
light/dark terminals).
## What the picker does today
- Static numbered list; navigate with arrows or `j`/`k`; `1``9` jump to a host.
- `enter` connects (exec ssh); `q`/`esc` quits.
- `/` opens an on-demand substring filter over alias/hostname/user/tags; `esc`
returns to the static list.
- `s` toggles sort between **recently used** and **most used** (persisted).
- `f` toggles a favourite; favourites pin to the top regardless of sort.
- A preview box shows the highlighted host's Host/HostName/User/Port/ProxyJump.
- Tags from `# tag:` comments show inline and are searchable.
## Current state
- **Complete and building.** All five platform binaries cross-compile
(~3.5 MB each). Native build via `make build`; all binaries via `make dist`.
- **Tested:** unit tests for the parser (includes/tags/edge cases), the store
(round-trip, corrupt-file fallback, favourites), the yubikey logic (enroll/
verify with a simulated key, recovery-code round-trip), and picker rendering/
overflow. `go vet` clean.
- **Performance:** parsing a 200-host config is ~0.3 ms; cold start <10 ms —
well under the "comfortably under 100 ms" target.
- **Live-verified via tmux:** navigation, on-demand filtering, favourite +
sort persistence to disk.
## Known limitations / not yet exercised
- **The YubiKey gate has never touched real hardware** — `ykman` isn't installed
on the dev box, so that path is only covered by unit tests with a simulated
HMAC key. Needs a real touch-test on a machine with a key. The exact `ykman`
subcommand (`ykman otp calculate SLOT CHALLENGE`) assumes ykman 5.x; older
versions used different command names.
- **No signing/notarization.** The macOS/Windows binaries are unsigned, so
Gatekeeper/SmartScreen will warn unless the user clears quarantine or builds
locally.
- **Not in version control** yet (no git repo initialised).
- **No release automation** (no CI, no GitHub releases, no Homebrew tap/scoop).
- **Match blocks** in ssh_config are recognised but their conditions aren't
evaluated (dial just doesn't attach stray keywords to a host). Wildcard
`Host *` patterns are excluded from the connectable list.
## Explicitly out of scope for v1
- Editing/managing `~/.ssh/config` from within the tool (strictly read-only).
- `known_hosts` management.
- Custom ssh-agent handling (relies on the OS ssh client).
## Open questions & directions to brainstorm
From the brief's "future/v2" plus things that came up:
1. **SFTP / file transfer mode.** After selecting a host, an alternate action to
send/receive files. Simplest: shell out to system `sftp`/`scp` (same exec
pattern). Fuller: an in-app file browser (remote listing, progress) built on
a Go SFTP library, following the same readable low-light list UI. The
picker is already decoupled to allow this.
2. **How to pick the action** (shell vs SFTP) once a host is selected — a
second keypress on the highlighted host? a mode toggle? a menu?
3. **Distribution/trust.** Worth signing/notarizing? A Homebrew tap + Scoop
manifest + an `install.sh`? GitHub Releases via CI?
4. **Gate ergonomics.** Every-launch touch is secure but can nag. Is an
opt-in idle-timeout worth adding later? Should the gate also cover the
future SFTP action?
5. **Recovery-code UX.** Currently terminal-only, shown once, single-use with
auto-rotation. Is that the right balance vs. something like multiple codes,
or a printable sheet?
6. **Richer metadata.** Tags exist; would grouping/sectioning by tag, or a tag
filter shortcut, be useful? Colour-coding by environment (prod/staging)?
7. **Search behaviour.** Filter is currently plain substring (predictable, not
fuzzy) to honour the "readable, not cramped" ethos. Is fuzzy matching wanted
as an option?
8. **Multi-select / batch actions** (e.g. run a command across several hosts) —
in scope for a tool like this, or scope creep?
9. **Connection history/analytics** beyond recents/frequency — last-connected
timestamps shown in the list? per-host notes?
## How to run/install (for reference)
- Build: `make build` (native) or `make dist` (all platforms).
- Install a binary onto PATH, e.g. Linux user-level:
`install -D -m 0755 dial-linux-amd64 ~/.local/bin/dial`.
- Use: `dial` (needs a `~/.ssh/config`; there's an `examples/ssh_config.sample`).
- Gate: `dial yubikey enable` (needs `ykman`).