From 8af44dfcd2e2f2d76fe5d789378b2fb42591f074 Mon Sep 17 00:00:00 2001 From: bsncubed Date: Wed, 22 Jul 2026 23:18:23 +1000 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20dial=20=E2=80=94=20interact?= =?UTF-8?q?ive=20SSH=20host=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/settings.local.json | 19 ++ .gitignore | 2 + Makefile | 36 +++ README.md | 233 +++++++++++++++++++ REPORT.md | 152 +++++++++++++ cmd/dial/keygen.go | 179 +++++++++++++++ cmd/dial/main.go | 333 ++++++++++++++++++++++++++++ create_ssh_keypair (1).sh | 64 ++++++ dial-brief.md | 131 +++++++++++ dial-v2-plan.md | 211 ++++++++++++++++++ docs/v2-report.md | 149 +++++++++++++ examples/ssh_config.sample | 24 ++ go.mod | 30 +++ go.sum | 47 ++++ internal/connect/connect.go | 24 ++ internal/connect/connect_unix.go | 21 ++ internal/connect/connect_windows.go | 33 +++ internal/keygen/keygen.go | 238 ++++++++++++++++++++ internal/keygen/keygen_test.go | 129 +++++++++++ internal/picker/picker.go | 240 ++++++++++++++++++++ internal/picker/render_test.go | 163 ++++++++++++++ internal/picker/update.go | 237 ++++++++++++++++++++ internal/picker/view.go | 310 ++++++++++++++++++++++++++ internal/sshconf/sshconf.go | 264 ++++++++++++++++++++++ internal/sshconf/sshconf_test.go | 106 +++++++++ internal/store/store.go | 298 +++++++++++++++++++++++++ internal/store/store_test.go | 82 +++++++ internal/theme/theme.go | 105 +++++++++ internal/yubikey/recovery.go | 95 ++++++++ internal/yubikey/yubikey.go | 161 ++++++++++++++ internal/yubikey/yubikey_test.go | 103 +++++++++ 31 files changed, 4219 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 REPORT.md create mode 100644 cmd/dial/keygen.go create mode 100644 cmd/dial/main.go create mode 100644 create_ssh_keypair (1).sh create mode 100644 dial-brief.md create mode 100644 dial-v2-plan.md create mode 100644 docs/v2-report.md create mode 100644 examples/ssh_config.sample create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/connect/connect.go create mode 100644 internal/connect/connect_unix.go create mode 100644 internal/connect/connect_windows.go create mode 100644 internal/keygen/keygen.go create mode 100644 internal/keygen/keygen_test.go create mode 100644 internal/picker/picker.go create mode 100644 internal/picker/render_test.go create mode 100644 internal/picker/update.go create mode 100644 internal/picker/view.go create mode 100644 internal/sshconf/sshconf.go create mode 100644 internal/sshconf/sshconf_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/theme/theme.go create mode 100644 internal/yubikey/recovery.go create mode 100644 internal/yubikey/yubikey.go create mode 100644 internal/yubikey/yubikey_test.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9976b75 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(sudo rm -rf /usr/local/go)", + "Bash(sudo tar -C /usr/local -xzf /tmp/go.tgz)", + "Bash(/usr/local/go/bin/go version *)", + "Bash(tmux send-keys *)", + "Bash(tmux capture-pane *)", + "Bash(ssh -o BatchMode=yes 192.168.192.201 'sha256sum ~/dial-push-test.txt')", + "Bash(tmux kill-session *)", + "Bash(rm -f ~/index.html ~/dial-push-test.txt)", + "Bash(ssh -o BatchMode=yes 192.168.192.201 'rm -f ~/dial-push-test.txt')", + "Bash(ssh -o BatchMode=yes 192.168.192.201 'ls ~')", + "Bash(ssh -o BatchMode=yes -o IdentitiesOnly=yes -i ~/.ssh/ssh-test-key -J ben@192.168.192.201 ben@192.168.192.201 'echo JUMP_OK on $\\(hostname\\); id -un')", + "Bash(ssh -o BatchMode=yes -o IdentitiesOnly=yes -i ~/.ssh/ssh-test-key 192.168.192.201 'echo on-box; nc -zv 192.168.192.201 22 2>&1 || \\(exec 3<>/dev/tcp/192.168.192.201/22 && echo tcp-self-OK && head -c 40 <&3\\)')", + "Bash(ssh -o BatchMode=yes -o IdentitiesOnly=yes -i ~/.ssh/ssh-test-key 192.168.192.201 'grep -i banner /etc/ssh/sshd_config 2>/dev/null; echo \"issue.net:\"; cat /etc/issue.net 2>/dev/null')" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4febd3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..718ba3c --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +VERSION ?= 0.1.0 +LDFLAGS := -s -w -X main.version=$(VERSION) +GO ?= go + +# Release targets: macOS Apple Silicon + Intel, Linux amd64 + arm64, Windows. +PLATFORMS := darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 + +.PHONY: build install test vet run dist clean + +build: ## Build a native binary into ./bin/dial + $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/dial ./cmd/dial + +install: ## Install dial into $GOBIN / $GOPATH/bin + $(GO) install -trimpath -ldflags "$(LDFLAGS)" ./cmd/dial + +test: ## Run tests + $(GO) test ./... + +vet: ## Run go vet + $(GO) vet ./... + +run: build ## Build and run + ./bin/dial + +dist: ## Cross-compile all release binaries into ./dist + @mkdir -p dist + @for p in $(PLATFORMS); do \ + os=$${p%/*}; arch=$${p#*/}; ext=""; \ + [ "$$os" = "windows" ] && ext=".exe"; \ + echo "building dial-$$os-$$arch$$ext"; \ + GOOS=$$os GOARCH=$$arch $(GO) build -trimpath -ldflags "$(LDFLAGS)" \ + -o dist/dial-$$os-$$arch$$ext ./cmd/dial || exit 1; \ + done + +clean: ## Remove build artifacts + rm -rf bin dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ada8b9 --- /dev/null +++ b/README.md @@ -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 `) | +| `/` | 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). diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 0000000..8e21747 --- /dev/null +++ b/REPORT.md @@ -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`). diff --git a/cmd/dial/keygen.go b/cmd/dial/keygen.go new file mode 100644 index 0000000..f6f131e --- /dev/null +++ b/cmd/dial/keygen.go @@ -0,0 +1,179 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/term" + + "gitea.apointless.space/bsncubed/dial/internal/keygen" +) + +// runKeygen implements `dial keygen [--rsa]`: generate a key pair and, +// optionally, append a matching Host block to ~/.ssh/config. +func runKeygen(args []string) error { + algo := keygen.AlgoED25519 + for _, a := range args { + switch a { + case "--rsa": + algo = keygen.AlgoRSA + case "--ed25519": + algo = keygen.AlgoED25519 + case "-h", "--help": + fmt.Println("Usage: dial keygen [--rsa]\n\nGenerate an SSH key pair (ed25519 by default) and optionally add a Host entry.") + return nil + default: + return fmt.Errorf("unknown keygen flag %q", a) + } + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + sshDir := filepath.Join(home, ".ssh") + in := bufio.NewReader(os.Stdin) + + fmt.Printf("%s (%s)\n", cyan("dial keygen"), algo) + + // Key filename. + name := ask(in, "Key name / filename", "") + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("a key name is required") + } + if strings.ContainsAny(name, "/\\") { + return fmt.Errorf("key name must not contain path separators") + } + + // Optional passphrase (hidden input, confirmed). + passphrase, err := askPassphrase(in) + if err != nil { + return err + } + + comment := name + fmt.Printf("Generating %s key…\n", algo) + res, err := keygen.Generate(algo, comment, passphrase) + if err != nil { + return err + } + privPath, pubPath, err := keygen.WriteKeyPair(sshDir, name, res) + if err != nil { + return err + } + fmt.Printf("%s\n private: %s\n public: %s\n", green("✓ key written"), privPath, pubPath) + + // Public key printed for convenience — the .pub file is kept, so this is + // not a last-chance display. + fmt.Println("\nPublic key (also saved to the .pub file above):") + fmt.Println(strings.TrimRight(string(res.PublicLine), "\n")) + + // Optional config entry. + if !askYesNo(in, "\nAdd a Host entry to ~/.ssh/config now?", false) { + fmt.Println("Done. No config entry added.") + return nil + } + + entry := keygen.HostEntry{ + IdentityFile: "~/.ssh/" + name, + } + entry.Alias = strings.TrimSpace(ask(in, "Host alias (what you'll type: ssh )", name)) + entry.HostName = strings.TrimSpace(ask(in, "HostName (IP or DNS)", "")) + entry.User = strings.TrimSpace(ask(in, "User", "")) + entry.Port = strings.TrimSpace(ask(in, "Port", "22")) + if tags := strings.TrimSpace(ask(in, "Tags (comma-separated, optional)", "")); tags != "" { + for _, t := range strings.Split(tags, ",") { + if t = strings.TrimSpace(t); t != "" { + entry.Tags = append(entry.Tags, t) + } + } + } + + cfgPath := filepath.Join(sshDir, "config") + backup, err := keygen.AppendHostEntry(cfgPath, entry) + if err != nil { + return err + } + if backup != "" { + fmt.Printf("%s (backup: %s)\n", green("✓ Host \""+entry.Alias+"\" added to ~/.ssh/config"), filepath.Base(backup)) + } else { + fmt.Printf("%s\n", green("✓ Host \""+entry.Alias+"\" added to ~/.ssh/config")) + } + fmt.Printf("\nConnect with: %s\n", cyan("dial")+" (then pick "+entry.Alias+") or ssh "+entry.Alias) + return nil +} + +// ask prints a prompt (with an optional default) and returns the entered line, +// falling back to def when the user just presses Enter. +func ask(in *bufio.Reader, prompt, def string) string { + if def != "" { + fmt.Printf("%s [%s]: ", blue(prompt), def) + } else { + fmt.Printf("%s: ", blue(prompt)) + } + line, _ := in.ReadString('\n') + line = strings.TrimRight(line, "\r\n") + if line == "" { + return def + } + return line +} + +func askYesNo(in *bufio.Reader, prompt string, def bool) bool { + hint := "y/N" + if def { + hint = "Y/n" + } + fmt.Printf("%s [%s]: ", blue(prompt), hint) + line, _ := in.ReadString('\n') + line = strings.ToLower(strings.TrimSpace(line)) + if line == "" { + return def + } + return line == "y" || line == "yes" +} + +// askPassphrase reads an optional passphrase twice (hidden), returning nil for +// an empty (passphraseless) key. Falls back to visible input on a non-terminal. +func askPassphrase(in *bufio.Reader) ([]byte, error) { + fmt.Print(blue("Passphrase (optional, Enter for none)") + ": ") + p1, err := readSecret(in) + if err != nil { + return nil, err + } + if len(p1) == 0 { + return nil, nil + } + fmt.Print(blue("Confirm passphrase") + ": ") + p2, err := readSecret(in) + if err != nil { + return nil, err + } + if string(p1) != string(p2) { + return nil, fmt.Errorf("passphrases did not match") + } + return p1, nil +} + +// readSecret reads a hidden line from a real terminal, or falls back to the +// shared buffered reader when stdin isn't a terminal (piped input) so the input +// stream stays in sync with the other prompts. +func readSecret(in *bufio.Reader) ([]byte, error) { + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + b, err := term.ReadPassword(fd) + fmt.Println() + return b, err + } + line, _ := in.ReadString('\n') + return []byte(strings.TrimRight(line, "\r\n")), nil +} + +// Minimal ANSI colour helpers for the keygen prompts, matching dial's palette. +func cyan(s string) string { return "\033[1;36m" + s + "\033[0m" } +func blue(s string) string { return "\033[1;34m" + s + "\033[0m" } +func green(s string) string { return "\033[1;32m" + s + "\033[0m" } diff --git a/cmd/dial/main.go b/cmd/dial/main.go new file mode 100644 index 0000000..ae79d9a --- /dev/null +++ b/cmd/dial/main.go @@ -0,0 +1,333 @@ +// Command dial is a fast, interactive SSH host picker. It reads ~/.ssh/config +// (read-only), lets you pick a host from a static, high-contrast list, and +// exec's the system ssh client into it. An optional YubiKey gate can be +// required before the picker unlocks. +package main + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" + + "gitea.apointless.space/bsncubed/dial/internal/connect" + "gitea.apointless.space/bsncubed/dial/internal/picker" + "gitea.apointless.space/bsncubed/dial/internal/sshconf" + "gitea.apointless.space/bsncubed/dial/internal/store" + "gitea.apointless.space/bsncubed/dial/internal/yubikey" +) + +// version is overridden at build time via -ldflags "-X main.version=...". +var version = "dev" + +// errAbort signals the user chose to quit at the gate. +var errAbort = errors.New("aborted") + +func main() { + args := os.Args[1:] + if len(args) > 0 { + switch args[0] { + case "-h", "--help", "help": + usage(os.Stdout) + return + case "-v", "--version", "version": + fmt.Println("dial", version) + return + case "yubikey", "yk": + if err := runYubikeyCmd(args[1:]); err != nil { + fatal(err) + } + return + case "keygen": + if err := runKeygen(args[1:]); err != nil { + fatal(err) + } + return + default: + fmt.Fprintf(os.Stderr, "dial: unknown command %q\n\n", args[0]) + usage(os.Stderr) + os.Exit(2) + } + } + if err := run(); err != nil { + if errors.Is(err, errAbort) { + os.Exit(1) + } + fatal(err) + } +} + +// run is the default flow: gate → pick → connect. Connecting exec's ssh and +// never returns; quitting the picker returns here so main can exit cleanly. +func run() error { + settings := store.LoadSettings() + + if err := runGate(&settings); err != nil { + return err + } + + cfgPath, err := sshconf.DefaultConfigPath() + if err != nil { + return err + } + + hosts, err := sshconf.Parse(cfgPath) + if err != nil { + return fmt.Errorf("parsing %s: %w", cfgPath, err) + } + if len(hosts) == 0 { + fmt.Fprintf(os.Stderr, "dial: no hosts found in %s\n", cfgPath) + return nil + } + + res, err := picker.Run(hosts, store.LoadData(), settings) + if err != nil { + return err + } + + switch res.Action { + case picker.ActionConnect: + // Record usage, then exec ssh (replaces the process on Unix). + _ = store.Record(res.Alias) + return connect.SSH(res.Alias).Exec() + + default: + return nil // user quit + } +} + +// runGate enforces the YubiKey gate when enabled. It runs before any TUI, as +// plain terminal I/O, and returns nil once the user unlocks (via key or backup +// code) or errAbort if they quit. +func runGate(settings *store.Settings) error { + if !settings.YubiKeyEnabled { + return nil + } + if err := yubikey.Available(); err != nil { + return err + } + if !settings.YubiKey.IsEnrolled() { + return errors.New("YubiKey gate is enabled but no key is enrolled; run `dial yubikey enable` again") + } + + in := bufio.NewReader(os.Stdin) + for { + fmt.Println() + fmt.Println(" 🔒 dial is locked.") + fmt.Println(" [1] Unlock with YubiKey (touch it when it blinks)") + fmt.Println(" [2] Use a one-time backup recovery code") + fmt.Println(" [q] Quit") + fmt.Print(" Choose [1]: ") + + choice := strings.TrimSpace(readLine(in)) + switch choice { + case "", "1": + fmt.Println(" Touch your YubiKey now…") + err := yubikey.Verify(settings.YubiKey, settings.YubiKeySlot) + if err == nil { + return nil + } + fmt.Fprintf(os.Stderr, " ✗ %v\n", err) + case "2": + fmt.Print(" Enter backup recovery code: ") + code := readLine(in) + if err := useRecoveryCode(settings, code, in); err != nil { + fmt.Fprintf(os.Stderr, " ✗ %v\n", err) + continue + } + return nil + case "q", "Q": + return errAbort + default: + fmt.Fprintln(os.Stderr, " ✗ unrecognised choice") + } + } +} + +// useRecoveryCode verifies a backup code, and on success invalidates it and +// issues a fresh single-use code (shown once) so it can never be reused. +func useRecoveryCode(settings *store.Settings, code string, in *bufio.Reader) error { + if !settings.Recovery.IsSet() { + return errors.New("no backup recovery code is set") + } + if !yubikey.VerifyRecoveryCode(code, settings.Recovery) { + return errors.New("incorrect recovery code") + } + // Single-use: rotate to a new code immediately. + newCode, newHash, err := yubikey.GenerateRecoveryCode() + if err != nil { + return fmt.Errorf("generating replacement code: %w", err) + } + settings.Recovery = newHash + if err := store.SaveSettings(*settings); err != nil { + return fmt.Errorf("saving rotated recovery code: %w", err) + } + fmt.Println() + fmt.Println(" ✓ Unlocked with backup code. That code is now used up.") + printRecoveryCode(newCode, "Your NEW one-time backup recovery code") + waitAck(in) + return nil +} + +func runYubikeyCmd(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "enable": + return enableGate() + case "disable": + return disableGate() + case "status", "": + return gateStatus() + case "recovery": + return rotateRecovery() + default: + return fmt.Errorf("unknown yubikey subcommand %q (want enable|disable|status|recovery)", sub) + } +} + +func enableGate() error { + settings := store.LoadSettings() + if settings.YubiKeyEnabled { + fmt.Println("YubiKey gate is already enabled. Re-enrolling will replace the current key and recovery code.") + } + if err := yubikey.Available(); err != nil { + return err + } + fmt.Printf("Enrolling YubiKey on slot %d.\n", settings.YubiKeySlot) + fmt.Println("Touch your YubiKey now to register it…") + cfg, err := yubikey.Enroll(settings.YubiKeySlot) + if err != nil { + return fmt.Errorf("enrolling YubiKey: %w", err) + } + code, hash, err := yubikey.GenerateRecoveryCode() + if err != nil { + return fmt.Errorf("generating recovery code: %w", err) + } + settings.YubiKeyEnabled = true + settings.YubiKey = cfg + settings.Recovery = hash + if err := store.SaveSettings(settings); err != nil { + return fmt.Errorf("saving settings: %w", err) + } + fmt.Println("\n✓ YubiKey gate enabled. dial will require your key on every launch.") + printRecoveryCode(code, "One-time backup recovery code") + fmt.Println("\n Store this now — it is shown ONCE and lets you in if the key is unavailable.") + fmt.Println(" It is single-use: a fresh code is issued after you use it.") + return nil +} + +func disableGate() error { + settings := store.LoadSettings() + if !settings.YubiKeyEnabled { + fmt.Println("YubiKey gate is already disabled.") + return nil + } + settings.YubiKeyEnabled = false + settings.YubiKey = store.YubiKeyConfig{} + settings.Recovery = store.RecoveryHash{} + if err := store.SaveSettings(settings); err != nil { + return err + } + fmt.Println("✓ YubiKey gate disabled.") + return nil +} + +func gateStatus() error { + settings := store.LoadSettings() + fmt.Printf("YubiKey gate: %s\n", enabledStr(settings.YubiKeyEnabled)) + fmt.Printf("Slot: %d\n", settings.YubiKeySlot) + fmt.Printf("Enrolled: %v\n", settings.YubiKey.IsEnrolled()) + fmt.Printf("Backup code: %s\n", presentStr(settings.Recovery.IsSet())) + if err := yubikey.Available(); err != nil { + fmt.Printf("ykman: NOT FOUND (%v)\n", err) + } else { + fmt.Printf("ykman: found on PATH\n") + } + return nil +} + +// rotateRecovery issues a new backup code after the user proves access (passes +// the gate), so a lost/exposed code can be replaced without disabling the gate. +func rotateRecovery() error { + settings := store.LoadSettings() + if !settings.YubiKeyEnabled { + return errors.New("YubiKey gate is not enabled") + } + if err := runGate(&settings); err != nil { + return err + } + code, hash, err := yubikey.GenerateRecoveryCode() + if err != nil { + return err + } + settings.Recovery = hash + if err := store.SaveSettings(settings); err != nil { + return err + } + printRecoveryCode(code, "New one-time backup recovery code") + return nil +} + +// --- small terminal helpers --- + +func printRecoveryCode(code, label string) { + line := strings.Repeat("─", len(code)+6) + fmt.Println() + fmt.Println(" " + label + ":") + fmt.Println(" ┌" + line + "┐") + fmt.Println(" │ " + code + " │") + fmt.Println(" └" + line + "┘") +} + +func waitAck(in *bufio.Reader) { + fmt.Print(" Press Enter once you've saved it… ") + readLine(in) +} + +func readLine(r *bufio.Reader) string { + s, _ := r.ReadString('\n') + return strings.TrimRight(s, "\r\n") +} + +func enabledStr(b bool) string { + if b { + return "ENABLED" + } + return "disabled" +} + +func presentStr(b bool) string { + if b { + return "set" + } + return "none" +} + +func usage(w *os.File) { + fmt.Fprint(w, `dial — interactive SSH host picker + +Usage: + dial Pick a host and connect + dial keygen [--rsa] Generate an SSH key (ed25519 default) + optional Host entry + dial yubikey enable Enable the YubiKey launch gate (enrolls key + backup code) + dial yubikey disable Disable the YubiKey gate + dial yubikey status Show gate status + dial yubikey recovery Issue a new one-time backup code (requires unlock) + dial version Print version + dial help Show this help + +In the picker: + ↑/↓ or j/k move enter connect + 1-9 jump s cycle sort g group by tag + / search f favourite q/esc quit +`) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "dial:", err) + os.Exit(1) +} diff --git a/create_ssh_keypair (1).sh b/create_ssh_keypair (1).sh new file mode 100644 index 0000000..52ea3b1 --- /dev/null +++ b/create_ssh_keypair (1).sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Clear the terminal screen +clear + +# Function to create an SSH key +create_ssh_key() { + local hostname=$1 + local username=$2 + local key_name=$3 + + # Define the file name for the key + local file_name="${username}@${hostname}${key_name}" + + # Generate the SSH key + ssh-keygen -t rsa -b 4096 -C "${username}@${hostname}" -f ~/.ssh/${file_name} -N "" + + # Add a suffix to the .pub file + local pub_file=~/.ssh/${file_name}.pub + sed -i '' "s/$/ ${key_name}/" ${pub_file} + # Display the public key + echo -e "\033[1;32mPublic key for ${file_name}. This will not be shown again, and the file has been deleted.\033[0m" + echo -e "\033[1;32mPlace the key in the authorized_keys file\033[0m" + cat ${pub_file} + rm ${pub_file} + echo -e "\033[1;32mSSH key created: ${file_name}\033[0m" + echo + +} + +# Prompt the user for hostname and username with colors +echo -e "\033[1;34mEnter the hostname:\033[0m" +read hostname +echo -e "\033[1;34mEnter the username:\033[0m" +read username + +# Prompt the user for the number of keys to create +echo -e "\033[1;34mEnter the number of keys to create:\033[0m" +read num_keys + +# Loop to create the specified number of keys +for ((i=1; i<=num_keys; i++)); do + echo -e "\033[1;34mEnter the key name for key $i:\033[0m" + read key_name + create_ssh_key $hostname $username $key_name +done + +echo -e "\033[1;32mAll keys created successfully, Time to add one of those files to the local .ssh/config file. To not do that, type ^C\033[0m" + +echo -e "\033[1;34mHostname to add to config\033[0m" +read ssh_host +echo +echo -e "\033[1;34mUser to add to config\033[0m" +read ssh_user +echo +echo -e "\033[1;34mKeyfile name to add to config\033[0m" +read ssh_keyfile +echo +echo "" >> ~/.ssh/config +echo "Host $ssh_host" >> ~/.ssh/config +echo " User $ssh_user" >> ~/.ssh/config +echo " IdentityFile ~/.ssh/$ssh_keyfile" >> ~/.ssh/config +echo "" >> ~/.ssh/config + diff --git a/dial-brief.md b/dial-brief.md new file mode 100644 index 0000000..755faa7 --- /dev/null +++ b/dial-brief.md @@ -0,0 +1,131 @@ +# Project Brief: Interactive SSH Host Picker CLI ("dial") + +## Overview +A fast, interactive command-line tool that reads `~/.ssh/config`, lets the user +fuzzy-select a host, and `exec`s the system `ssh` binary into it. Cross-platform +(macOS Apple Silicon/M4, Linux, Windows). Optional YubiKey gate before the app +will run. App-specific data lives inside the `.ssh` tree so it rides along on +existing Syncthing sync across machines. + +## Goals +- Parse `~/.ssh/config`, including `Include` directives. +- Present an interactive, fuzzy-searchable list of `Host` entries. +- On selection, `exec` the real `ssh` binary (not a wrapped subprocess) so the + terminal/tty, `ProxyJump`, agent forwarding, etc. all just work. +- Cold start fast enough to feel instant (target: comfortably under 100ms). +- Ship as a single static binary per platform — no runtime dependency. +- Optional YubiKey challenge-response gate required before the picker unlocks. +- Store app data (recents, favorites, tags, settings) under `~/.ssh/` so it + syncs automatically via the existing Syncthing setup — no new sync config + needed. + +## Core Features +1. **Config parsing** + - Read `~/.ssh/config`, follow `Include` lines. + - Surface `Hostname`, `User`, `Port`, `ProxyJump` as preview info. + - Optional lightweight tagging via comment convention, e.g. + `# tag: prod, riedel` above a `Host` block — used for filtering/grouping. + - Never write to `~/.ssh/config` — strictly read-only. + +2. **Interactive picker** + - **Static, numbered list** — not a live fuzzy-filter-as-you-type interface. + Explicitly avoid the cramped/fuzzy-filter style seen in tools like the + `rclone` installer, which is hard to read in low light. Prioritize + legibility: larger clear text, generous spacing between entries, high + contrast, no small dense text packed together. + - Navigate with arrow keys or number keys, enter to connect, esc to quit. + - Optional `/` to jump into a search/filter mode on demand (not the + default view) if the host list gets long — but the resting state should + always be a clean, static, readable list. + - Sort by most-recently-used or most-frequently-used, toggleable. + - Show a small preview area (Hostname/User/Port/Jump host) for the + highlighted entry, in equally readable, high-contrast text. + +3. **Connect** + - `exec` (or platform equivalent) into `ssh ` so control of the + terminal passes cleanly to the ssh session — this app should not remain + a parent process wrapping it. + +4. **YubiKey gate (optional, toggle in settings)** + - HMAC-SHA1 challenge-response (YubiKey slot 2), fully offline — no + internet or server dependency. + - Implemented by shelling out to `ykman` (Yubico's official CLI) rather + than a direct PC/SC library. Trade-off accepted: `ykman` (and its + Python dependency) must be installed separately on every machine this + runs on — this breaks the "single static binary, zero deps" goal for + the YubiKey feature specifically, but keeps the implementation simple + and leans on Yubico's own well-tested tool. `dial` should check for + `ykman` on `PATH` at startup and give a clear error if it's missing. + - Require physical touch to confirm. + - **Backup 2FA code**: generate a one-time recovery code when the gate is + first enabled, shown once and expected to be stored by the user + somewhere separate (password manager, printed, etc.). This code lets + the user in once if the YubiKey isn't available, avoiding total + lockout. After use it should be invalidated/regenerated, not reusable + indefinitely. + +5. **Sync-safe app data** + - New directory: `~/.ssh/.dial/` + - `settings.json` — yubikey on/off + slot, theme, sort preference, etc. + - `data.json` — recents, favorites, tag cache. + - Multiple machines may write this concurrently via Syncthing — design for + that: last-write-wins is acceptable for v1, but reads should tolerate a + mid-sync/partial file without crashing (e.g. read failure just falls + back to defaults rather than erroring out). + - Nothing secret is ever written here — the YubiKey secret never leaves + the key itself in challenge-response mode, so there's no key material to + accidentally sync. + +## Non-Functional Requirements +- No telemetry, no network calls (beyond ssh itself). +- Read-only with respect to `~/.ssh/config`. +- Graceful degradation if no YubiKey is plugged in and the gate is enabled. +- Works identically on macOS (Apple Silicon), Linux, and Windows — including + Windows' different `ssh.exe` path/behavior. +- **Readable in low light**: default view is a static list with clear, + well-spaced, high-contrast text — not a dense fuzzy-search UI. This is a + hard requirement, not a nice-to-have. + +## Recommended Tech Stack +**Go** — chosen over your usual Python/Bash default specifically because this +needs true single-binary cross-platform distribution and fast cold start; +Python's interpreter startup and packaging story (PyInstaller etc.) are a +worse fit here. + +- TUI: `charmbracelet/bubbletea` + `lipgloss` (or simpler: `charmbracelet/huh` + for a lighter list-picker if a full TUI is overkill). +- SSH config parsing: `kevinburke/ssh_config`. +- YubiKey challenge-response: shell out to `ykman` (simplest, but adds an + external dependency) or use a Go PC/SC library directly for a fully + self-contained binary — worth deciding based on how important "zero + external deps" is to you. +- Cross-compile via `GOOS`/`GOARCH` for the three targets from one machine. + +*Alternative:* Rust would give similar performance/single-binary benefits +with a steeper setup cost — mention if you'd rather Claude Code default to +that instead. + +## Out of Scope (v1) +- Editing/managing `~/.ssh/config` from within the tool. +- Remote host key (known_hosts) management. +- Any custom ssh-agent handling — rely on the OS-native ssh client already + installed. + +## Future / v2 Considerations +Not in scope for v1, but the architecture should not preclude these — worth +keeping the "select a host" logic reusable/decoupled from the "connect via +ssh" action so these can be added later without a rewrite: +- **SFTP file transfer**: after selecting a host, an alternate action to + send/receive files instead of opening a shell session. + - Simplest version: shell out to the system `sftp`/`scp` binary, same + pattern as the `ssh` exec flow. + - Fuller version: an in-app file browser (source/dest picker, remote + directory listing, progress) built against a Go SFTP library + (e.g. `pkg/sftp`) rather than shelling out — should follow the same + readable, high-contrast, low-light-friendly list UI as the host picker, + not the cramped style of a bare `sftp` prompt. + +## Open Decisions (Claude Code should confirm before building) +- YubiKey gate: required every launch, or only after an idle timeout? +- Exact format/length of the backup recovery code, and where it should be + displayed/stored on generation (e.g. printed to terminal only, once). diff --git a/dial-v2-plan.md b/dial-v2-plan.md new file mode 100644 index 0000000..d05a606 --- /dev/null +++ b/dial-v2-plan.md @@ -0,0 +1,211 @@ +# dial — v2 Plan + +Written against the v1 REPORT.md handoff. v1 is complete, tested, and out of +scope to modify except where noted. This plan is scoped to be handed to +Claude Code as-is; every judgment call is flagged so it can be overridden. + +--- + +## 1. Alphabetical sort (small, do first) + +- Add a third sort mode to the existing `s` toggle: **recently used → most + used → alphabetical → (back to recently used)**. +- Alphabetical sorts by `Host` alias (the thing the user actually types/sees + first in the list), case-insensitive. +- Favourites still pin to the top regardless of sort mode (unchanged v1 + behaviour). +- Persist the selected mode the same way recents/frequency already persist + (`~/.ssh/.dial/data.json`). + +No open questions here — this is a straightforward extension of existing code. + +--- + +## 2. SSH key generation tool (`dial keygen`) + +Rebuilt from the logic in `create_ssh_keypair.sh`, fixing the cross-platform +and UX issues found in it, and wired into `dial` as a new subcommand rather +than folded into the interactive picker. + +**Why a separate subcommand, not part of the picker:** the picker's core +read-only-config guarantee is a deliberate v1 property. Keygen necessarily +*writes* to `~/.ssh/config` and creates key files, so it should be a distinct, +explicitly-invoked action (`dial keygen`) — not something that can happen by +accidentally pressing a key while browsing hosts. + +### Fixes over the original script +- Replace the macOS-only `sed -i ''` public-key-comment logic with something + that works identically on macOS/Linux/Windows (Go string handling, no shell + `sed` dependency at all). +- Default algorithm: **ed25519**. Offer `--rsa` (4096-bit) as an explicit + opt-out for hosts that don't support ed25519 (some older network gear, e.g. + legacy broadcast equipment, may not). +- **Keep the `.pub` file.** Don't delete it after displaying it once — that + behaviour in the original script means a lost copy-paste = a wasted key. + Still print it to the terminal for convenience, but leave the file in place. +- Proper quoting/escaping when appending to `~/.ssh/config` (avoid the blind + `read` + string-concat approach in the original). + +### Proposed flow +``` +dial keygen + → prompt: key name / label + → prompt: algorithm (default ed25519, flag for rsa) + → generate into ~/.ssh/