8af44dfcd2
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>
153 lines
8.0 KiB
Markdown
153 lines
8.0 KiB
Markdown
# 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`).
|