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:
@@ -0,0 +1,233 @@
|
||||
# dial
|
||||
|
||||
A fast, interactive SSH host picker. `dial` reads your `~/.ssh/config`
|
||||
(strictly read-only), shows a clean, static, high-contrast list of hosts, and
|
||||
`exec`s the real `ssh` client into the one you pick — so your tty, `ProxyJump`,
|
||||
agent forwarding, and everything else just work.
|
||||
|
||||
- **Single static binary** — the picker and key generation are self-contained
|
||||
(see [Requirements](#requirements) for the few things that lean on the system
|
||||
`ssh`/`ykman`).
|
||||
- **Cross-platform**: macOS (Apple Silicon + Intel), Linux (amd64 + arm64),
|
||||
Windows.
|
||||
- **Fast cold start** — parsing a 200-host config takes well under a
|
||||
millisecond.
|
||||
- **Readable in low light**: the resting view is a static, numbered,
|
||||
generously spaced list. No cramped fuzzy-filter-as-you-type UI.
|
||||
- **Sort & group** by recency, frequency, or name; group by tag with coloured
|
||||
tag pills; pin favourites.
|
||||
- **Key generation** (`dial keygen`) — native ed25519/RSA keys with an optional
|
||||
matching `~/.ssh/config` entry.
|
||||
- **Optional YubiKey gate** with an offline HMAC-SHA1 challenge-response and a
|
||||
one-time backup recovery code.
|
||||
- **Sync-safe app data** stored under `~/.ssh/.dial/` so it rides along on your
|
||||
existing Syncthing sync — no new sync config needed.
|
||||
|
||||
## Install
|
||||
|
||||
`dial` is a single binary. "Installing" just means putting it in a directory
|
||||
that's on your `PATH` and making it executable. Grab a prebuilt binary from
|
||||
`dist/` (`make dist`) or build one with `make build`.
|
||||
|
||||
Pick the binary for your machine:
|
||||
|
||||
| Platform | Binary |
|
||||
| ------------------------ | ------------------------- |
|
||||
| macOS Apple Silicon (M-series) | `dial-darwin-arm64` |
|
||||
| macOS Intel | `dial-darwin-amd64` |
|
||||
| Linux x86-64 | `dial-linux-amd64` |
|
||||
| Linux ARM64 (e.g. Pi, servers) | `dial-linux-arm64` |
|
||||
| Windows x86-64 | `dial-windows-amd64.exe` |
|
||||
|
||||
### Linux
|
||||
|
||||
Put it in a directory that's on `PATH`. `install -D` copies the file, sets the
|
||||
executable bit, and creates the destination directory if it's missing — so a
|
||||
single line works either way. Two common choices:
|
||||
|
||||
```sh
|
||||
# System-wide (all users) — needs sudo, always on PATH:
|
||||
sudo install -D -m 0755 dial-linux-amd64 /usr/local/bin/dial
|
||||
|
||||
# Just you (no sudo):
|
||||
install -D -m 0755 dial-linux-amd64 ~/.local/bin/dial
|
||||
```
|
||||
|
||||
`~/.local/bin` is on `PATH` by default on most modern distros. If `dial`
|
||||
isn't found after installing there, add it:
|
||||
|
||||
```sh
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc
|
||||
exec $SHELL # reload your shell
|
||||
```
|
||||
|
||||
Verify: `dial version`.
|
||||
|
||||
### macOS
|
||||
|
||||
Apple Silicon uses `dial-darwin-arm64`; Intel Macs use `dial-darwin-amd64`.
|
||||
macOS ships the BSD `install`, which has no `-D` flag, so create the directory
|
||||
first:
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /usr/local/bin
|
||||
sudo install -m 0755 dial-darwin-arm64 /usr/local/bin/dial
|
||||
```
|
||||
|
||||
`/usr/local/bin` is on `PATH` by default. Because the binary isn't
|
||||
code-signed, Gatekeeper may quarantine it if it arrived via a browser or
|
||||
AirDrop — clear that once (harmless if it wasn't quarantined):
|
||||
|
||||
```sh
|
||||
xattr -d com.apple.quarantine /usr/local/bin/dial 2>/dev/null
|
||||
dial version
|
||||
```
|
||||
|
||||
(Binaries copied over `scp`/`rsync` or built locally are not quarantined.)
|
||||
|
||||
For the optional YubiKey gate: `brew install ykman`.
|
||||
|
||||
### Windows
|
||||
|
||||
Put `dial-windows-amd64.exe` somewhere permanent as `dial.exe` and add that
|
||||
folder to `PATH`. In PowerShell:
|
||||
|
||||
```powershell
|
||||
$dir = "$env:LOCALAPPDATA\Programs\dial"
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
Copy-Item dial-windows-amd64.exe "$dir\dial.exe"
|
||||
# Add to your user PATH (persists across sessions):
|
||||
[Environment]::SetEnvironmentVariable(
|
||||
"Path", [Environment]::GetEnvironmentVariable("Path","User") + ";$dir", "User")
|
||||
```
|
||||
|
||||
Open a new terminal, then `dial version`. Windows uses the OpenSSH `ssh.exe`
|
||||
that ships in `%WINDIR%\System32\OpenSSH`.
|
||||
|
||||
### Build from source instead
|
||||
|
||||
Requires Go 1.23+.
|
||||
|
||||
```sh
|
||||
make install # builds and installs into $GOBIN (or $GOPATH/bin, ~/go/bin)
|
||||
```
|
||||
|
||||
Make sure that bin dir is on `PATH`:
|
||||
|
||||
```sh
|
||||
echo 'export PATH="$(go env GOPATH)/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc
|
||||
```
|
||||
|
||||
Or just `make build` and copy `bin/dial` wherever you like (see the per-OS
|
||||
steps above).
|
||||
|
||||
## Requirements
|
||||
|
||||
The binary itself has no dependencies, but individual features lean on tools you
|
||||
already have:
|
||||
|
||||
| Feature | Needs |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| Picker + connect | the system `ssh` client (already installed) |
|
||||
| Key generation | nothing — keys are generated natively |
|
||||
| YubiKey gate | Yubico's [`ykman`](https://developers.yubico.com/yubikey-manager/) CLI |
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
dial # pick a host and connect
|
||||
```
|
||||
|
||||
In the picker:
|
||||
|
||||
| Key | Action |
|
||||
| ---------------- | ------------------------------------------------- |
|
||||
| `↑`/`↓`, `j`/`k` | move the selection |
|
||||
| `1`–`9` | jump to that numbered host |
|
||||
| `enter` | connect (exec `ssh <host>`) |
|
||||
| `/` | open the on-demand search/filter |
|
||||
| `s` | cycle sort: recently used → most used → name |
|
||||
| `g` | toggle grouping by tag |
|
||||
| `f` | toggle favourite (favourites sort first) |
|
||||
| `q` / `esc` | quit |
|
||||
|
||||
The list resting state is always the clean static view. Search is opt-in via
|
||||
`/`; `esc` returns you to the static list.
|
||||
|
||||
### Generating keys
|
||||
|
||||
```sh
|
||||
dial keygen # ed25519 by default; --rsa for RSA-4096
|
||||
```
|
||||
|
||||
Prompts for a key name, an optional passphrase, and (optionally) a matching
|
||||
`Host` entry to add to `~/.ssh/config`. The private key is written `0600`, the
|
||||
`.pub` is kept, and any config append backs up the file first and refuses to
|
||||
duplicate an existing alias. dial never overwrites an existing key.
|
||||
|
||||
### Tags
|
||||
|
||||
Add a comment above a `Host` block to tag it. Tags show in the list and are
|
||||
searchable:
|
||||
|
||||
```
|
||||
# tag: prod, riedel
|
||||
Host web1
|
||||
HostName 10.0.0.5
|
||||
User deploy
|
||||
```
|
||||
|
||||
### Includes
|
||||
|
||||
`Include` directives are followed (globs and `~` are expanded, relative paths
|
||||
resolve against `~/.ssh`), so hosts spread across multiple files all appear.
|
||||
|
||||
## YubiKey gate (optional)
|
||||
|
||||
When enabled, `dial` requires a YubiKey touch on every launch before the picker
|
||||
unlocks. It uses slot 2 by default, fully offline.
|
||||
|
||||
This feature shells out to Yubico's official [`ykman`](https://developers.yubico.com/yubikey-manager/)
|
||||
CLI, which must be installed separately (this is the one part that isn't a
|
||||
zero-dependency single binary — a deliberate trade to lean on Yubico's own
|
||||
tooling). `dial` checks for `ykman` on `PATH` and reports a clear error if it's
|
||||
missing.
|
||||
|
||||
```sh
|
||||
dial yubikey enable # enroll your key (touch it) + print a one-time backup code
|
||||
dial yubikey status # show current gate state
|
||||
dial yubikey recovery # issue a fresh backup code (after unlocking)
|
||||
dial yubikey disable # turn the gate off
|
||||
```
|
||||
|
||||
**Backup recovery code.** Enabling the gate prints a one-time recovery code
|
||||
**once** — store it somewhere separate (password manager, printed). It lets you
|
||||
in if the key isn't available. It is single-use: after you use it, a fresh code
|
||||
is issued and 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 challenge and
|
||||
a *salted hash* of the expected response; the recovery code is stored only as a
|
||||
salted hash.
|
||||
|
||||
## App data (`~/.ssh/.dial/`)
|
||||
|
||||
- `settings.json` — gate on/off + slot, theme, sort preference, and the hashed
|
||||
recovery code / YubiKey verification record.
|
||||
- `data.json` — recents, usage frequency, favourites.
|
||||
|
||||
Both are written atomically (temp file + rename). Reads tolerate a partial or
|
||||
corrupt file mid-sync by falling back to defaults, and concurrent writes across
|
||||
machines are last-write-wins.
|
||||
|
||||
## Building releases
|
||||
|
||||
```sh
|
||||
make dist # cross-compiles all platforms into ./dist
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
`dial` does no telemetry and makes no network calls beyond `ssh` to the hosts
|
||||
you pick. The picker treats `~/.ssh/config` as read-only; the only write dial
|
||||
ever makes is `dial keygen` adding a `Host` block (with a backup).
|
||||
Reference in New Issue
Block a user