Files
dial/REPORT.md
T
bsncubed 8af44dfcd2 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>
2026-07-22 23:18:23 +10:00

8.0 KiB
Raw Blame History

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 execs 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; 19 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 hardwareykman 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).