# 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.25+.

```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).
