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:
2026-07-22 23:18:23 +10:00
commit 8af44dfcd2
31 changed files with 4219 additions and 0 deletions
+19
View File
@@ -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')"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
/bin/
/dist/
+36
View File
@@ -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
+233
View File
@@ -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).
+152
View File
@@ -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`).
+179
View File
@@ -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 <alias>)", 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" }
+333
View File
@@ -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)
}
+64
View File
@@ -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
+131
View File
@@ -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 <alias>` 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).
+211
View File
@@ -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/<label> (and .pub alongside, kept)
→ prompt: add a Host entry to ~/.ssh/config now? (y/n)
→ if yes: Host alias / HostName / User / Port (default 22) / tag (optional, using existing `# tag:` convention)
→ print the public key once for easy copy, key file left on disk
```
**Assumption flagged:** this reuses `dial`'s existing static-list, low-light-
friendly prompt styling rather than plain unstyled `read` prompts like the
original script, for consistency with the rest of the tool.
---
## 3. Remaining v2 features (from REPORT.md open questions)
Going through the report's nine open questions individually, with a decision
made and flagged for each so this can go straight to Claude Code.
### 3.1 SFTP / file transfer mode — **in scope for v2: full in-app browser**
- After selecting a host, a second action (`t` for transfer, alongside `enter`
to shell in) opens an **in-app SFTP file browser** — not a shell-out to the
system `sftp` binary.
- Built directly against a Go SFTP library (`pkg/sftp` + `golang.org/x/crypto/
ssh` for the underlying connection) rather than shelling out, so dial owns
the whole UI.
- **Core browser UX** (following the same static, high-contrast, low-light-
friendly list styling as the host picker — no cramped/dense text):
- Split view or toggle-view of local vs. remote directory listing.
- Arrow-key navigation through directories on both sides; `enter` on a
directory descends into it, a clear "up one level" entry at the top of
each listing.
- **Batch selection:** a keypress (e.g. `space`) toggles selection on the
highlighted entry without moving the cursor, same pattern as v1's `f`
favourite toggle. Multiple files (and/or directories) can be selected
before triggering a push/pull.
- Push (local → remote) or pull (remote → local) on the current selection —
a single file, multiple files, a single directory, or a mix.
- **Directory transfers are recursive**, walking the full tree on the
source side and recreating structure on the destination side.
- Progress UI: per-item progress plus an aggregate "3 of 12 files, 40% of
total" view when a batch/directory transfer is in flight — legible at the
same size/contrast as the rest of the UI.
- Clear error states: connection drop mid-transfer, permission denied, disk
full, file already exists (prompt overwrite/skip/rename rather than
silently clobbering) — applies per-item within a batch, with an option to
apply the same choice to all remaining conflicts rather than prompting
per file.
- **Resume on failure: basic, same-session only.** If a transfer (or a batch/
directory transfer) is interrupted — network drop, connection reset — and
retried within the same `dial` session, dial seeks both the local and
remote file to the last confirmed byte offset and continues rather than
restarting from zero. This applies per-file within a batch/directory
transfer (finished files stay finished; only the interrupted one resumes).
- **Explicitly not included:** resume that survives closing/restarting
`dial` or the machine. That requires persisting transfer state to disk and
verifying partial files weren't corrupted before trusting them — a
meaningfully bigger, separate piece of work. Flag if this turns out to
matter in practice (e.g. regularly moving very large files where a lost
session is costly) and it can be scoped as a v2.x follow-up.
- Reuses the connection details already resolved from `~/.ssh/config` for the
selected host (same auth, same `ProxyJump` if present) — no separate
credential entry.
- **Gate coverage:** as already decided in §3.4, the YubiKey gate (when
enabled) covers entry into this mode too, not just app launch.
### 3.2 How to pick the action (shell vs SFTP) — **decided**
- Second keypress on the highlighted host, not a separate menu: `enter` = ssh,
`t` = sftp. Keeps the static-list simplicity intact rather than adding a
submenu/modal.
### 3.3 Distribution/trust (signing, Homebrew, Scoop, CI) — **deferred, not v2**
- This is packaging/ops work, not a feature, and doesn't block daily use on
your own machines via Syncthing + manual install. Flagging as a separate
future task rather than bundling into this feature-focused v2 pass.
### 3.4 Gate ergonomics — **small change: gate now also covers SFTP**
- Every-launch touch stays as-is (no idle-timeout added — the report notes
this as a "worth adding later" maybe, not a v2 must-have).
**Assumption flagged:** leaving idle-timeout out of v2; say the word if you
want it added now instead.
- The YubiKey gate (when enabled) now also gates the new `t` (SFTP) action,
not just app launch, since it's a second way to move data off/onto a host.
### 3.5 Recovery-code UX — **unchanged**
- Current design (terminal-only, shown once, single-use, auto-rotates) stays
as-is. No changes requested and the report doesn't flag it as a problem.
### 3.6 Richer metadata (tag grouping/colour-coding) — **in scope, small**
- Add optional **section headers by tag** in the static list when sorted
alphabetically or by tag (new sort mode candidate — see note below).
- Colour-code tag pills using the existing `internal/theme` high-contrast
palette (e.g. a distinct colour per tag), not free-form colour — keeps the
low-light-readable requirement intact.
- **Open question for you, not decided here:** should "group by tag" be its
own 4th+ sort mode, or a toggle independent of sort? Recommend: independent
toggle (`g` to group), since it's orthogonal to alphabetical/recent/most-used
ordering. Flagging for Claude Code to confirm with you if ambiguous during
build.
### 3.7 Search behaviour (fuzzy vs substring) — **kept as substring, not changed**
- Report explicitly chose substring to honour the "readable, not cramped"
ethos. No reason surfaced in this conversation to revisit — leaving as-is
for v2.
### 3.8 Multi-select / batch actions across hosts — **deferred, flagged as scope creep**
- Note: this is distinct from the batch *file* selection now included in the
SFTP browser (§3.1), which is about selecting multiple files/folders within
one host's transfer session. This item is about running a command or
transfer across *multiple hosts* at once — the report itself raises this as
possible scope creep. Recommend leaving it out of v2 — that's a meaningfully
different tool shape (more like an orchestration tool than a picker) and
deserves its own dedicated design pass if wanted later, not a bolt-on here.
### 3.9 Connection history/analytics beyond recents — **small addition**
- Add a **last-connected timestamp** shown in the preview pane (already
tracking recency internally for sort — this just surfaces it visually).
- Per-host notes: **deferred** — no clear use case surfaced yet; easy to add
later without restructuring anything, so not worth the effort now.
---
## v2 Scope Summary
**Building now:**
1. Alphabetical sort mode
2. `dial keygen` subcommand (cross-platform, ed25519-default, config-append)
3. In-app SFTP file browser (`t` to open, `pkg/sftp`-based — local/remote
listings, batch file/folder selection, recursive directory transfers,
same-session resume-on-failure, aggregate progress, overwrite prompts)
4. Tag grouping toggle + colour-coded tag pills
5. YubiKey gate extended to cover the new SFTP browser
6. Last-connected timestamp in preview pane
**Explicitly deferred (not this pass):**
- Resume that survives closing/restarting dial (crash-persistent resume)
- Idle-timeout gate option
- Distribution/signing/CI/package managers
- Multi-select/batch actions *across hosts* (§3.8) — distinct from in-browser
batch file selection, which is now in scope
- Per-host notes
- Fuzzy search
---
## Notes for Claude Code
- The SFTP browser is a genuinely new subsystem (`internal/sftp` or similar),
not a reuse of `internal/connect`'s exec-handover pattern — it needs its own
connection lifecycle via `pkg/sftp`/`golang.org/x/crypto/ssh` since dial
stays in the foreground driving the UI, rather than handing off the process
like the SSH connect flow does.
- `dial keygen` is a new top-level subcommand alongside the existing
`yubikey enable/disable/status/recovery`, `version`, `help` — not part of
the TUI picker's keybindings.
- Confirm with the user during implementation: tag-grouping as toggle vs sort
mode (§3.6), if it's not obvious once in the code.
- Given the SFTP browser is now the largest single piece of v2, worth
sequencing it last (after alphabetical sort, keygen, tag grouping, last-
connected timestamp) so the smaller wins land first and the browser gets
full attention/testing time.
+149
View File
@@ -0,0 +1,149 @@
# dial — v2 report
A status report on `dial` after the v2 pass, written to be self-contained so it
can be pasted into a fresh chat for brainstorming. No codebase access needed to
follow it. (For the original tool, see the v1 REPORT.md; this focuses on what v2
added and where it stands.)
## What dial is (one paragraph)
`dial` is a fast, cross-platform command-line SSH tool. Its core is an
interactive host picker: it reads `~/.ssh/config`, shows a clean, static,
high-contrast, low-light-friendly list, and on selection `exec`s the real system
`ssh` client into the host (so tty/ProxyJump/agent forwarding just work). It's a
single Go binary. v2 added sorting/grouping, an SSH key generator, and an in-app
SFTP file browser. Optional features include a YubiKey launch gate. App data
(recents, favourites, settings) lives under `~/.ssh/.dial/` so it syncs via the
user's existing Syncthing.
## What v2 shipped
1. **Alphabetical sort.** The `s` key now cycles three modes: recently used →
most used → name. Favourites still pin to the top in every mode. Persisted.
2. **Last-connected timestamp** shown in the host preview pane ("1 hour ago" /
"never"), surfacing the recency data already tracked for sorting.
3. **Tag grouping + coloured tag pills.** A `g` toggle (independent of sort)
groups the list into sections: a Favourites section, one section per tag
(alphabetical), then Untagged. Tag pills are coloured deterministically per
tag from the theme's high-contrast palette. **Decision made:** grouping is an
independent toggle (not a 4th sort mode), and a multi-tagged host appears
under its *first* tag only, so the list stays a clean permutation with no
duplicated rows.
4. **`dial keygen`.** A new subcommand that generates SSH keys **natively in Go**
(no `ssh-keygen` dependency): ed25519 by default, `--rsa` for RSA-4096.
Optional passphrase (default none). It keeps the `.pub` file (the old shell
script deleted it), writes the private key `0600`, and can append a matching
`Host` block to `~/.ssh/config` — backing the file up first and refusing to
duplicate an existing alias. It's a rebuild of an old `create_ssh_keypair.sh`,
fixing that script's macOS-only `sed`, key-deletion, and unquoted-append bugs.
5. **In-app SFTP file browser.** Pressing `t` on a host opens a dual-pane
browser (local left, remote right) that transfers files without leaving dial:
- Side-by-side panes, `tab` to switch, arrow/`jk` to move, `enter` to descend,
`backspace` up, `space` to batch-select.
- Directional transfers: `→`/`p` push local→remote, `←`/`g` pull remote→local,
into the other pane's current directory.
- Recursive directory transfers, aggregate progress bar, and overwrite prompts
(`[o]verwrite`/`[s]kip`/`[r]ename`, uppercase = apply to all remaining).
- **Same-session resume**: an interrupted transfer retried in the same session
resumes partial files instead of restarting.
- Connects over SFTP in-process (`pkg/sftp` + `golang.org/x/crypto/ssh`),
reusing the host's real `~/.ssh/config` (resolved via `ssh -G`), including
ProxyJump, authenticating via ssh-agent or unencrypted key files.
- When the YubiKey gate is enabled it re-challenges before opening the browser.
## Architecture (packages)
Existing v1 packages: `sshconf` (read-only config parser), `store`
(`~/.ssh/.dial/` persistence), `picker` (the TUI), `connect` (exec-into-ssh),
`yubikey` (gate + recovery code), `theme` (styling). v2 added:
- `internal/keygen` — native key generation (crypto + `x/crypto/ssh`), key-file
writing with correct perms, and safe `~/.ssh/config` appends.
- `internal/xfer` — the transfer engine. A small `fs` abstraction lets push and
pull share one recursive copy routine; handles progress, conflict policy, and
resume. **Fully unit-tested against an in-memory SFTP server** (no sshd needed).
- `internal/sshx` — the connection layer. Resolves effective config via
`ssh -G <alias>`, builds the `ssh.ClientConfig` (agent + key auth, known_hosts
verification), and dials directly or through a ProxyJump chain.
- `internal/browser` — the dual-pane Bubble Tea file browser, using `xfer` for
transfers and coordinating the overwrite prompt across goroutines.
The picker's `t` key returns an `ActionSFTP` result; `main` then (re-)runs the
gate, connects via `sshx`, opens an `sftp.Client`, and runs the browser. The
default flow is now a loop: pick → connect (exec, ends) or sftp (returns to the
picker). Host *selection* stayed decoupled from the *action*, which is what let
SFTP reuse the picker cleanly.
## Current state
- **Complete and green.** All packages build; `go vet` clean; unit tests pass
for `sshconf`, `store`, `yubikey`, `picker`, `keygen`, `xfer`, `sshx`.
- **Cross-compiles** to all five targets (macOS arm64/amd64, Linux amd64/arm64,
Windows amd64). Binary grew from ~3.5 MB to ~6.1 MB — the cost of the
in-process SFTP stack (`x/crypto/ssh` + `pkg/sftp`), a deliberate trade chosen
over shelling out to the system `sftp`.
- **Live-verified via tmux** (against an in-memory SFTP server): dual-pane
render, navigation, batch selection, single-file push, recursive directory
push (contents verified on the far side), and the overwrite-conflict prompt
(goroutine↔UI coordination). keygen verified end-to-end (valid ed25519,
correct perms, config append with backup).
## Known limitations / not yet exercised
- **The SFTP connection layer is now live-verified against a real sshd.**
Against a test host (key-based auth, no agent, unencrypted key loaded directly
from `IdentityFile`): `sshx.Connect` + SFTP listing, an SFTP write/read/remove
round-trip, and a full-TUI pull *and* push both passed with byte-for-byte
sha256 matches. An env-gated live test (`DIAL_LIVE_HOST=<host> go test
./internal/sshx -run TestLiveConnect`) is kept for re-running this. Still
unverified against a real host: **ProxyJump chaining** and **agent-backed /
encrypted-key auth** (the test host used a plain key file, no agent). (Same
hardware-untested posture remains for the YubiKey gate.)
- **ssh-agent on Windows** uses named pipes, not the `SSH_AUTH_SOCK` unix socket
the code dials; it compiles, but agent auth on Windows likely needs follow-up.
- **Encrypted key files** are skipped by the SFTP auth (it relies on the agent
for those) — dial never prompts for a key passphrase when connecting.
- **No mid-transfer cancel** — resume covers interruptions, but there's no
in-flight cancel key; a stuck transfer would need Ctrl-C out of the browser.
- **known_hosts is verify-only** — an unknown host errors out ("ssh to it once
first") rather than offering trust-on-first-use; no known_hosts management.
- Still unsigned binaries (Gatekeeper/SmartScreen warnings); no CI/releases.
## Deferred (explicitly out of this pass)
Idle-timeout gate option; crash-persistent resume (surviving a dial restart);
distribution/signing/package managers; multi-select/batch actions *across hosts*
(distinct from the in-browser batch file selection, which shipped); per-host
notes; fuzzy search.
## Open questions & directions to brainstorm
1. **Real-host validation plan for SFTP** — what's the cheapest way to smoke-test
the connection layer (a throwaway VM? a container running sshd? a known host)?
2. **Windows agent support** — is Windows a real target for the SFTP browser, or
is it macOS/Linux-first? Determines whether named-pipe agent support is worth
building.
3. **Cancel & crash-persistent resume** — how often do transfers of very large
files over flaky links actually happen? That's the trigger for investing in
disk-persisted transfer state.
4. **known_hosts UX** — is verify-only acceptable, or should the browser offer to
record a new host key (with a clear prompt)?
5. **Grouping refinements** — should multi-tagged hosts optionally appear under
*every* tag? Should there be a per-tag filter shortcut or colour legend?
6. **Distribution** — Homebrew tap / Scoop / `install.sh` / signed releases via
CI: worth doing now that the feature set is broad?
7. **SFTP polish** — a file preview? delete/rename/mkdir on either side? a "sync
this directory" one-shot? These edge toward a full file manager — where's the
line for a tool that's fundamentally a host picker?
## How to build / try
- Build: `make build` (native) or `make dist` (all platforms). Requires Go 1.23+.
- Keys: `dial keygen` (no external deps).
- SFTP browser: `dial`, pick a host, press `t` (needs the host in known_hosts and
agent/key auth working — i.e. a host you can already `ssh` to).
- YubiKey gate: `dial yubikey enable` (requires Yubico's `ykman`).
+24
View File
@@ -0,0 +1,24 @@
# Example ~/.ssh/config showing dial's tag convention and Includes.
# dial never writes to this file — it is read-only.
Host *
ServerAliveInterval 60
# tag: prod, riedel
Host web1 web2
HostName 10.0.0.5
User deploy
Port 2222
ProxyJump bastion
# tag: prod
Host db-primary
HostName db.internal
User postgres
Host bastion
HostName bastion.example.com
User admin
# Split the rest of your hosts across files; dial follows Includes.
Include conf.d/*.conf
+30
View File
@@ -0,0 +1,30 @@
module gitea.apointless.space/bsncubed/dial
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/crypto v0.54.0
golang.org/x/term v0.45.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
+47
View File
@@ -0,0 +1,47 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+24
View File
@@ -0,0 +1,24 @@
// Package connect turns a selected host alias into a handover to the system's
// real ssh client. It is deliberately decoupled from the picker: the picker's
// job ends at "here is an alias", and connect's job is "hand the terminal to
// ssh <alias>". This keeps the select-a-host logic decoupled from the handover.
package connect
// Command describes an external client invocation that dial hands the terminal
// over to. dial never wraps or proxies the session; it exec's the real binary.
type Command struct {
Bin string // program to run, resolved via PATH (e.g. "ssh", "sftp")
Args []string // arguments after the program name (argv0 is added by Exec)
}
// SSH opens an interactive shell session on alias, letting the system ssh
// client re-resolve everything (ProxyJump, agent forwarding, tty, etc.).
func SSH(alias string) Command {
return Command{Bin: "ssh", Args: []string{alias}}
}
// Exec hands terminal control to the command. On Unix this replaces the current
// process image, so it only returns if the exec itself fails (e.g. the binary
// is missing). On Windows it runs the child with inherited stdio and exits with
// the child's status, so it likewise does not return on success.
func (c Command) Exec() error { return execCommand(c) }
+21
View File
@@ -0,0 +1,21 @@
//go:build !windows
package connect
import (
"os"
"os/exec"
"syscall"
)
// execCommand replaces the current process with the target binary via execve.
// This is what makes ssh's tty/ProxyJump/agent-forwarding "just work": dial is
// gone, and ssh owns the terminal directly with no wrapping parent.
func execCommand(c Command) error {
path, err := exec.LookPath(c.Bin)
if err != nil {
return err
}
argv := append([]string{c.Bin}, c.Args...)
return syscall.Exec(path, argv, os.Environ())
}
+33
View File
@@ -0,0 +1,33 @@
//go:build windows
package connect
import (
"errors"
"os"
"os/exec"
)
// execCommand runs the target as a child with inherited stdio because Windows
// has no execve-style process replacement. dial waits and then exits with the
// child's status, so from the user's perspective control still passes cleanly
// to ssh.exe (found on PATH; typically %WINDIR%\System32\OpenSSH).
func execCommand(c Command) error {
path, err := exec.LookPath(c.Bin)
if err != nil {
return err
}
cmd := exec.Command(path, c.Args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
os.Exit(ee.ExitCode())
}
return err
}
os.Exit(0)
return nil // unreachable
}
+238
View File
@@ -0,0 +1,238 @@
// Package keygen creates SSH key pairs natively in Go (no ssh-keygen
// dependency) and, optionally, appends a matching Host block to ~/.ssh/config.
//
// It is a rebuild of an older create_ssh_keypair.sh, fixing that script's
// cross-platform and safety problems:
// - no macOS-only `sed -i ''` (public-key comments are handled in Go);
// - the .pub file is kept, not deleted after display;
// - ed25519 by default, RSA-4096 as an explicit opt-out;
// - config appends are quoted/validated, back up the file first, and refuse
// to create a duplicate Host alias.
package keygen
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
)
// Algorithm selects the key type.
type Algorithm string
const (
AlgoED25519 Algorithm = "ed25519"
AlgoRSA Algorithm = "rsa"
rsaBits = 4096
)
// Result holds the encoded key material ready to write to disk.
type Result struct {
PrivatePEM []byte // OpenSSH-format private key (optionally passphrase-encrypted)
PublicLine []byte // authorized_keys line, comment appended, trailing newline
}
// Generate creates a key pair of the given algorithm. comment is embedded in
// the private key and appended to the public line. A non-empty passphrase
// encrypts the private key at rest.
func Generate(algo Algorithm, comment string, passphrase []byte) (Result, error) {
var privKey crypto.PrivateKey
var pubKey crypto.PublicKey
switch algo {
case AlgoRSA:
k, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
return Result{}, err
}
privKey, pubKey = k, &k.PublicKey
case AlgoED25519, "":
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return Result{}, err
}
privKey, pubKey = priv, pub
default:
return Result{}, fmt.Errorf("unknown algorithm %q", algo)
}
var block *pem.Block
var err error
if len(passphrase) > 0 {
block, err = ssh.MarshalPrivateKeyWithPassphrase(privKey, comment, passphrase)
} else {
block, err = ssh.MarshalPrivateKey(privKey, comment)
}
if err != nil {
return Result{}, fmt.Errorf("encoding private key: %w", err)
}
sshPub, err := ssh.NewPublicKey(pubKey)
if err != nil {
return Result{}, err
}
pubLine := strings.TrimRight(string(ssh.MarshalAuthorizedKey(sshPub)), "\n")
if comment != "" {
pubLine += " " + comment
}
pubLine += "\n"
return Result{PrivatePEM: pem.EncodeToMemory(block), PublicLine: []byte(pubLine)}, nil
}
// WriteKeyPair writes the private key (0600) and public key (0644) into sshDir
// as <name> and <name>.pub. It refuses to overwrite existing files so a stray
// re-run can't clobber a key still in use.
func WriteKeyPair(sshDir, name string, r Result) (privPath, pubPath string, err error) {
if err = os.MkdirAll(sshDir, 0o700); err != nil {
return "", "", err
}
privPath = filepath.Join(sshDir, name)
pubPath = privPath + ".pub"
if exists(privPath) || exists(pubPath) {
return privPath, pubPath, fmt.Errorf("key files for %q already exist; refusing to overwrite", name)
}
if err = os.WriteFile(privPath, r.PrivatePEM, 0o600); err != nil {
return privPath, pubPath, err
}
if err = os.WriteFile(pubPath, r.PublicLine, 0o644); err != nil {
return privPath, pubPath, err
}
return privPath, pubPath, nil
}
// HostEntry describes a Host block to append to ~/.ssh/config.
type HostEntry struct {
Alias string
HostName string
User string
Port string
IdentityFile string
Tags []string
}
// AliasExists reports whether the config already declares the given Host alias.
func AliasExists(configPath, alias string) (bool, error) {
hosts, err := sshconf.Parse(configPath)
if err != nil {
return false, err
}
for _, h := range hosts {
if h.Alias == alias {
return true, nil
}
}
return false, nil
}
// AppendHostEntry appends a Host block to configPath. It validates fields
// (rejecting whitespace/newlines that could break or inject config), backs up
// an existing config first, and refuses to duplicate an existing alias.
func AppendHostEntry(configPath string, e HostEntry) (backup string, err error) {
if err := validateEntry(e); err != nil {
return "", err
}
if exists(configPath) {
dup, err := AliasExists(configPath, e.Alias)
if err != nil {
return "", err
}
if dup {
return "", fmt.Errorf("Host %q already exists in %s", e.Alias, configPath)
}
backup = fmt.Sprintf("%s.%s.bak", configPath, time.Now().Format("20060102-150405"))
if err := copyFile(configPath, backup); err != nil {
return "", fmt.Errorf("backing up config: %w", err)
}
} else if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
return "", err
}
var b strings.Builder
b.WriteString("\n")
if len(e.Tags) > 0 {
b.WriteString("# tag: " + strings.Join(e.Tags, ", ") + "\n")
}
b.WriteString("Host " + e.Alias + "\n")
if e.HostName != "" {
b.WriteString(" HostName " + e.HostName + "\n")
}
if e.User != "" {
b.WriteString(" User " + e.User + "\n")
}
if e.Port != "" && e.Port != "22" {
b.WriteString(" Port " + e.Port + "\n")
}
if e.IdentityFile != "" {
b.WriteString(" IdentityFile " + e.IdentityFile + "\n")
b.WriteString(" IdentitiesOnly yes\n")
}
f, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return backup, err
}
defer f.Close()
if _, err := f.WriteString(b.String()); err != nil {
return backup, err
}
return backup, nil
}
func validateEntry(e HostEntry) error {
if strings.TrimSpace(e.Alias) == "" {
return fmt.Errorf("host alias is required")
}
fields := map[string]string{
"alias": e.Alias, "hostname": e.HostName, "user": e.User,
"port": e.Port, "identityfile": e.IdentityFile,
}
for name, v := range fields {
if strings.ContainsAny(v, "\n\r") {
return fmt.Errorf("%s contains a newline", name)
}
}
if strings.ContainsAny(e.Alias, " \t") {
return fmt.Errorf("host alias %q must not contain spaces", e.Alias)
}
for _, t := range e.Tags {
if strings.ContainsAny(t, "\n\r,") {
return fmt.Errorf("tag %q must not contain newlines or commas", t)
}
}
return nil
}
func exists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
+129
View File
@@ -0,0 +1,129 @@
package keygen
import (
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/ssh"
)
func TestGenerateED25519(t *testing.T) {
r, err := Generate(AlgoED25519, "test@dial", nil)
if err != nil {
t.Fatal(err)
}
// Private key must parse back as an OpenSSH key.
if _, err := ssh.ParseRawPrivateKey(r.PrivatePEM); err != nil {
t.Fatalf("private key does not parse: %v", err)
}
// Public line must parse and carry the comment.
pk, comment, _, _, err := ssh.ParseAuthorizedKey(r.PublicLine)
if err != nil {
t.Fatalf("public key does not parse: %v", err)
}
if pk.Type() != ssh.KeyAlgoED25519 {
t.Errorf("expected ed25519, got %s", pk.Type())
}
if comment != "test@dial" {
t.Errorf("comment = %q, want test@dial", comment)
}
}
func TestGenerateWithPassphrase(t *testing.T) {
r, err := Generate(AlgoED25519, "c", []byte("hunter2"))
if err != nil {
t.Fatal(err)
}
if _, err := ssh.ParseRawPrivateKey(r.PrivatePEM); err == nil {
t.Error("encrypted key should not parse without passphrase")
}
if _, err := ssh.ParseRawPrivateKeyWithPassphrase(r.PrivatePEM, []byte("hunter2")); err != nil {
t.Errorf("key should parse with passphrase: %v", err)
}
}
func TestGenerateRSA(t *testing.T) {
r, err := Generate(AlgoRSA, "c", nil)
if err != nil {
t.Fatal(err)
}
pk, _, _, _, err := ssh.ParseAuthorizedKey(r.PublicLine)
if err != nil {
t.Fatal(err)
}
if pk.Type() != ssh.KeyAlgoRSA {
t.Errorf("expected rsa, got %s", pk.Type())
}
}
func TestWriteKeyPairPermsAndNoOverwrite(t *testing.T) {
dir := t.TempDir()
r, _ := Generate(AlgoED25519, "c", nil)
priv, pub, err := WriteKeyPair(dir, "mykey", r)
if err != nil {
t.Fatal(err)
}
fi, _ := os.Stat(priv)
if fi.Mode().Perm() != 0o600 {
t.Errorf("private key perms = %o, want 600", fi.Mode().Perm())
}
pfi, _ := os.Stat(pub)
if pfi.Mode().Perm() != 0o644 {
t.Errorf("public key perms = %o, want 644", pfi.Mode().Perm())
}
// Second write must refuse to overwrite.
if _, _, err := WriteKeyPair(dir, "mykey", r); err == nil {
t.Error("expected refusal to overwrite existing key")
}
}
func TestAppendHostEntryBackupAndDup(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config")
if err := os.WriteFile(cfg, []byte("Host existing\n HostName e\n"), 0o600); err != nil {
t.Fatal(err)
}
backup, err := AppendHostEntry(cfg, HostEntry{
Alias: "newhost", HostName: "10.0.0.1", User: "ben", Port: "22",
IdentityFile: "~/.ssh/mykey", Tags: []string{"prod"},
})
if err != nil {
t.Fatal(err)
}
if backup == "" {
t.Error("expected a backup path")
}
if _, err := os.Stat(backup); err != nil {
t.Errorf("backup not written: %v", err)
}
b, _ := os.ReadFile(cfg)
got := string(b)
for _, want := range []string{"# tag: prod", "Host newhost", "HostName 10.0.0.1", "IdentitiesOnly yes"} {
if !strings.Contains(got, want) {
t.Errorf("config missing %q\n%s", want, got)
}
}
// Port 22 is the default and should be omitted.
if strings.Contains(got, "Port 22") {
t.Error("default port 22 should not be written")
}
// Appending the same alias must be refused.
if _, err := AppendHostEntry(cfg, HostEntry{Alias: "newhost", HostName: "x"}); err == nil {
t.Error("expected duplicate-alias refusal")
}
}
func TestValidateRejectsInjection(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config")
if _, err := AppendHostEntry(cfg, HostEntry{Alias: "a b"}); err == nil {
t.Error("alias with space should be rejected")
}
if _, err := AppendHostEntry(cfg, HostEntry{Alias: "ok", HostName: "x\n ProxyCommand evil"}); err == nil {
t.Error("newline injection should be rejected")
}
}
+240
View File
@@ -0,0 +1,240 @@
// Package picker is dial's interactive host list. Per the brief it is a static,
// numbered, high-contrast list — NOT a fuzzy-filter-as-you-type UI. The resting
// state is always a clean, well-spaced, readable list; a search/filter mode is
// available on demand via "/" but is never the default view.
//
// The picker only *selects* a host. Connecting is the caller's job (see package
// connect), keeping selection decoupled from what's done with the chosen host.
package picker
import (
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
"gitea.apointless.space/bsncubed/dial/internal/store"
"gitea.apointless.space/bsncubed/dial/internal/theme"
)
// Action is what the user chose to do with the selected host. The type leaves
// room for additional actions to slot in without reworking the result plumbing.
type Action int
const (
ActionNone Action = iota
ActionConnect
)
// Result reports the outcome of a picker session.
type Result struct {
Alias string // selected host alias; empty if the user quit
Action Action
}
// row is a host enriched with its usage stats and favourite flag.
type row struct {
host sshconf.Host
count int
last time.Time
fav bool
}
type model struct {
th theme.Theme
rows []row // immutable set of all hosts
sortM store.SortMode
sorted []int // indices into rows, in sorted order (favourites first)
view []int // indices into rows currently shown (sorted, filtered, grouped)
grouped bool // section the list by tag
headers map[int]sectionHead // view position -> section header shown before it
cursor int // position within view (a host row)
top int // scroll offset, in rendered lines
// winFirst/winLast are the first/last host positions visible after the last
// render, used for the "showing XY of N" header hint.
winFirst, winLast int
filtering bool
query string
width, height int
status string // transient one-line message
result Result
quitting bool
}
// sectionHead labels a group section in grouped mode. tag is the colour key
// (empty for the Favourites/untagged sections, which render neutral).
type sectionHead struct {
title string
tag string
}
// Run displays the picker and blocks until the user connects or quits. It
// persists sort-mode and favourite changes via the store as they happen. The
// returned Result carries the chosen alias (empty if quit).
func Run(hosts []sshconf.Host, data store.Data, settings store.Settings) (Result, error) {
m, _ := buildModel(hosts, data, settings)
p := tea.NewProgram(m, tea.WithAltScreen())
fm, err := p.Run()
if err != nil {
return Result{}, err
}
return fm.(*model).result, nil
}
// buildModel constructs an initialised model (rows enriched, sorted, filtered)
// without starting a tea program, so tests can render it directly.
func buildModel(hosts []sshconf.Host, data store.Data, settings store.Settings) (*model, error) {
fav := map[string]bool{}
for _, f := range data.Favorites {
fav[f] = true
}
rows := make([]row, len(hosts))
for i, h := range hosts {
r := row{host: h, fav: fav[h.Alias]}
if st := data.Stats[h.Alias]; st != nil {
r.count = st.Count
r.last = st.LastUsed
}
rows[i] = r
}
m := &model{
th: theme.New(settings.Theme),
rows: rows,
sortM: settings.Sort,
grouped: settings.GroupByTag,
}
m.sortAll()
m.applyFilter()
return m, nil
}
func (m *model) Init() tea.Cmd { return nil }
// sortAll rebuilds m.sorted: favourites first, then by the active sort mode,
// ties broken by alias for stable, predictable ordering.
func (m *model) sortAll() {
idx := make([]int, len(m.rows))
for i := range idx {
idx[i] = i
}
less := func(a, b int) bool {
ra, rb := m.rows[a], m.rows[b]
if ra.fav != rb.fav {
return ra.fav // favourites float to the top
}
switch m.sortM {
case store.SortFrequent:
if ra.count != rb.count {
return ra.count > rb.count
}
case store.SortAlpha:
// Alias-only ordering; handled by the shared tiebreaker below.
default: // SortRecent
if !ra.last.Equal(rb.last) {
return ra.last.After(rb.last)
}
}
return strings.ToLower(ra.host.Alias) < strings.ToLower(rb.host.Alias)
}
sort.SliceStable(idx, func(i, j int) bool { return less(idx[i], idx[j]) })
m.sorted = idx
}
// applyFilter recomputes m.view from m.sorted using the current query. An empty
// query shows everything (the resting state). Matching is a case-insensitive
// substring over alias, hostname, user, and tags — predictable, not fuzzy.
func (m *model) applyFilter() {
q := strings.ToLower(strings.TrimSpace(m.query))
filtered := make([]int, 0, len(m.sorted))
for _, i := range m.sorted {
if q == "" || rowMatches(m.rows[i], q) {
filtered = append(filtered, i)
}
}
if m.grouped {
m.view, m.headers = m.regroup(filtered)
} else {
m.view, m.headers = filtered, nil
}
if m.cursor >= len(m.view) {
m.cursor = len(m.view) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
m.ensureVisible()
}
// regroup reorders the filtered hosts into sections: a Favourites section
// first (if any), then one section per tag (tags sorted alphabetically), then
// an "untagged" section. A host is placed under its first tag only, so the view
// stays a clean permutation with no duplicated rows. Within each section, the
// active sort order is preserved (favourites are pulled out, so their tag
// sections don't repeat them).
func (m *model) regroup(filtered []int) ([]int, map[int]sectionHead) {
var favs, untagged []int
buckets := map[string][]int{}
var tagOrder []string
seenTag := map[string]bool{}
for _, vi := range filtered {
r := m.rows[vi]
if r.fav {
favs = append(favs, vi)
continue
}
if len(r.host.Tags) == 0 {
untagged = append(untagged, vi)
continue
}
t := r.host.Tags[0]
if !seenTag[t] {
seenTag[t] = true
tagOrder = append(tagOrder, t)
}
buckets[t] = append(buckets[t], vi)
}
sort.Strings(tagOrder)
view := make([]int, 0, len(filtered))
headers := map[int]sectionHead{}
addSection := func(h sectionHead, hosts []int) {
if len(hosts) == 0 {
return
}
headers[len(view)] = h
view = append(view, hosts...)
}
addSection(sectionHead{title: "★ favourites"}, favs)
for _, t := range tagOrder {
addSection(sectionHead{title: t, tag: t}, buckets[t])
}
addSection(sectionHead{title: "untagged"}, untagged)
return view, headers
}
func rowMatches(r row, q string) bool {
h := r.host
if strings.Contains(strings.ToLower(h.Alias), q) ||
strings.Contains(strings.ToLower(h.HostName), q) ||
strings.Contains(strings.ToLower(h.User), q) {
return true
}
for _, t := range h.Tags {
if strings.Contains(strings.ToLower(t), q) {
return true
}
}
return false
}
+163
View File
@@ -0,0 +1,163 @@
package picker
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/lipgloss"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
func sampleModel() *model {
hosts := []sshconf.Host{
{Alias: "web1", HostName: "10.0.0.5", User: "deploy", Port: "2222", ProxyJump: "bastion", Tags: []string{"prod", "riedel"}},
{Alias: "db-primary", HostName: "db.internal", User: "postgres", Port: "22", Tags: []string{"prod"}},
{Alias: "bastion", HostName: "bastion.example.com", User: "admin"},
{Alias: "staging-web", HostName: "10.1.0.9", User: "deploy", Tags: []string{"staging"}},
}
data := store.DefaultData()
data.Stats["web1"] = &store.HostStat{Count: 9, LastUsed: time.Now()}
data.Stats["bastion"] = &store.HostStat{Count: 3, LastUsed: time.Now().Add(-time.Hour)}
data.Favorites = []string{"bastion"}
settings := store.DefaultSettings()
m := newModelForTest(hosts, data, settings)
m.width, m.height = 90, 30
m.ensureVisible()
return m
}
// newModelForTest mirrors the setup Run does, without starting a tea program.
func newModelForTest(hosts []sshconf.Host, data store.Data, settings store.Settings) *model {
res, _ := buildModel(hosts, data, settings)
return res
}
func TestRenderSnapshot(t *testing.T) {
m := sampleModel()
out := m.View()
if !strings.Contains(out, "dial") {
t.Error("header missing")
}
if !strings.Contains(out, "web1") || !strings.Contains(out, "bastion") {
t.Error("hosts missing from render")
}
// bastion is a favourite, so it should sort to the top of a recent-sort list.
t.Logf("\n%s", out)
}
func TestFilterNarrows(t *testing.T) {
m := sampleModel()
m.query = "staging"
m.applyFilter()
if len(m.view) != 1 {
t.Fatalf("expected 1 match for 'staging', got %d", len(m.view))
}
if m.rows[m.view[0]].host.Alias != "staging-web" {
t.Errorf("wrong match: %s", m.rows[m.view[0]].host.Alias)
}
}
func TestFavoriteSortsFirst(t *testing.T) {
m := sampleModel()
if len(m.view) == 0 {
t.Fatal("no rows")
}
if !m.rows[m.view[0]].fav {
t.Errorf("favourite should be first, got %s", m.rows[m.view[0]].host.Alias)
}
}
func TestAlphaSort(t *testing.T) {
m := sampleModel()
m.sortM = store.SortAlpha
m.sortAll()
m.applyFilter()
// Favourite (bastion) still pins first; the rest are alphabetical.
got := []string{}
for _, vi := range m.view {
got = append(got, m.rows[vi].host.Alias)
}
want := []string{"bastion", "db-primary", "staging-web", "web1"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("alpha order = %v, want %v", got, want)
}
}
func TestSortCycle(t *testing.T) {
s := store.SortRecent
seq := []store.SortMode{}
for i := 0; i < 3; i++ {
s = s.Next()
seq = append(seq, s)
}
want := []store.SortMode{store.SortFrequent, store.SortAlpha, store.SortRecent}
for i := range want {
if seq[i] != want[i] {
t.Errorf("cycle[%d] = %s, want %s", i, seq[i], want[i])
}
}
}
func TestGrouping(t *testing.T) {
m := sampleModel()
m.grouped = true
m.applyFilter()
// Expect a Favourites header first, then tag sections, then no untagged
// (bastion, the only untagged host, is a favourite).
if h, ok := m.headers[0]; !ok || !strings.Contains(h.title, "favourites") {
t.Fatalf("first section should be favourites, got %+v", m.headers[0])
}
// bastion (fav) appears once, only in the favourites section.
count := 0
for _, vi := range m.view {
if m.rows[vi].host.Alias == "bastion" {
count++
}
}
if count != 1 {
t.Errorf("favourite host should appear exactly once, got %d", count)
}
// Every non-header host is reachable; view length equals host count (no dupes).
if len(m.view) != len(m.rows) {
t.Errorf("grouped view should contain each host once: got %d, want %d", len(m.view), len(m.rows))
}
// A tag section header should carry the tag as its colour key.
foundProd := false
for _, h := range m.headers {
if h.title == "prod" && h.tag == "prod" {
foundProd = true
}
}
if !foundProd {
t.Error("expected a 'prod' tag section header")
}
}
func TestNoOverflow(t *testing.T) {
for _, grouped := range []bool{false, true} {
for _, w := range []int{40, 60, 90, 140} {
for _, h := range []int{18, 24, 40} {
m := sampleModel()
m.grouped = grouped
m.applyFilter()
m.width, m.height = w, h
out := m.View()
lines := strings.Split(out, "\n")
if len(lines) > h {
t.Errorf("grouped=%v %dx%d: %d lines > height", grouped, w, h, len(lines))
}
for _, ln := range lines {
if lw := lipgloss.Width(ln); lw > w {
t.Errorf("grouped=%v %dx%d: line %d cols > width", grouped, w, h, lw)
}
}
}
}
}
}
+237
View File
@@ -0,0 +1,237 @@
package picker
import (
tea "github.com/charmbracelet/bubbletea"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// Layout budget (lines) reserved around the scrolling list.
const (
headerLines = 2 // title + blank spacer
footerLines = 1 // key hints
previewLines = 8 // bordered preview box (2 border + 6 fields)
linesPerRow = 3 // alias line + detail line + blank spacer
)
// visibleListLines returns how many rendered lines the scrolling list may
// occupy, reserving space for the header, preview box, and footer.
func (m *model) visibleListLines() int {
reserved := headerLines + previewLines + footerLines + 1 // +1 slack for separators
if m.filtering {
reserved++ // filter input line
}
n := m.height - reserved
if n < linesPerRow {
return linesPerRow // always show at least one host block
}
return n
}
// visibleCount estimates how many host rows fit on screen, used only as the
// page size for PgUp/PgDn.
func (m *model) visibleCount() int {
n := m.visibleListLines() / linesPerRow
if n < 1 {
return 1
}
return n
}
// ensureVisible clamps the cursor to a valid host position. The actual line
// scrolling (m.top) is computed during render, where header/host line heights
// are known.
func (m *model) ensureVisible() {
if m.cursor >= len(m.view) {
m.cursor = len(m.view) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ensureVisible()
return m, nil
case tea.KeyMsg:
if m.filtering {
return m.updateFiltering(msg)
}
return m.updateNormal(msg)
}
return m, nil
}
// updateNormal handles keys in the resting (static list) state.
func (m *model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = ""
switch msg.String() {
case "ctrl+c", "q", "esc":
m.quitting = true
return m, tea.Quit
case "up", "k", "ctrl+p":
m.move(-1)
case "down", "j", "ctrl+n":
m.move(1)
case "home":
m.cursor = 0
m.ensureVisible()
case "end", "G":
m.cursor = len(m.view) - 1
m.ensureVisible()
case "pgup":
m.move(-m.visibleCount())
case "pgdown":
m.move(m.visibleCount())
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
// Jump the cursor to the row bearing that number (1-based, absolute).
n := int(msg.String()[0] - '0')
if n >= 1 && n <= len(m.view) {
m.cursor = n - 1
m.ensureVisible()
}
case "/":
m.filtering = true
m.query = ""
return m, nil
case "s":
// Cycle sort mode (recent → frequent → alpha) and persist the preference.
m.sortM = m.sortM.Next()
m.persistSort()
m.reindex()
case "g":
// Toggle tag grouping (orthogonal to sort) and persist.
m.grouped = !m.grouped
m.persistGroup()
if m.grouped {
m.status = "grouped by tag"
} else {
m.status = "grouping off"
}
m.reindex()
case "f":
m.toggleFavorite()
case "enter":
return m.selectWith(ActionConnect)
}
return m, nil
}
// updateFiltering handles keys while the on-demand search box is open.
func (m *model) updateFiltering(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "ctrl+c":
// Leave filter mode and return to the clean, static resting list.
m.filtering = false
m.query = ""
m.applyFilter()
return m, nil
case "enter":
return m.selectWith(ActionConnect)
case "up", "ctrl+p":
m.move(-1)
return m, nil
case "down", "ctrl+n":
m.move(1)
return m, nil
case "backspace":
if len(m.query) > 0 {
// Trim one rune from the query.
r := []rune(m.query)
m.query = string(r[:len(r)-1])
m.applyFilter()
}
return m, nil
default:
if msg.Type == tea.KeyRunes {
m.query += string(msg.Runes)
m.applyFilter()
}
return m, nil
}
}
func (m *model) move(delta int) {
if len(m.view) == 0 {
return
}
m.cursor += delta
if m.cursor < 0 {
m.cursor = 0
}
if m.cursor >= len(m.view) {
m.cursor = len(m.view) - 1
}
m.ensureVisible()
}
// reindex re-sorts and re-filters while keeping the highlighted host under the
// cursor where possible.
func (m *model) reindex() {
var keepAlias string
if m.cursor >= 0 && m.cursor < len(m.view) {
keepAlias = m.rows[m.view[m.cursor]].host.Alias
}
m.sortAll()
m.applyFilter()
if keepAlias != "" {
for i, vi := range m.view {
if m.rows[vi].host.Alias == keepAlias {
m.cursor = i
break
}
}
}
m.ensureVisible()
}
func (m *model) selectWith(action Action) (tea.Model, tea.Cmd) {
if m.cursor < 0 || m.cursor >= len(m.view) {
return m, nil
}
m.result = Result{Alias: m.rows[m.view[m.cursor]].host.Alias, Action: action}
m.quitting = true
return m, tea.Quit
}
func (m *model) toggleFavorite() {
if m.cursor < 0 || m.cursor >= len(m.view) {
return
}
ri := m.view[m.cursor]
alias := m.rows[ri].host.Alias
on, err := store.ToggleFavorite(alias)
if err != nil {
m.status = "could not save favourite: " + err.Error()
return
}
m.rows[ri].fav = on
if on {
m.status = "★ favourited " + alias
} else {
m.status = "removed favourite " + alias
}
m.reindex()
}
func (m *model) persistSort() {
s := store.LoadSettings()
s.Sort = m.sortM
_ = store.SaveSettings(s) // non-fatal: preference only
}
func (m *model) persistGroup() {
s := store.LoadSettings()
s.GroupByTag = m.grouped
_ = store.SaveSettings(s) // non-fatal: preference only
}
+310
View File
@@ -0,0 +1,310 @@
package picker
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
func (m *model) View() string {
if m.quitting {
return ""
}
// renderList must run first: it computes the scroll window and sets
// winFirst/winLast, which the header's "showing" hint reads.
list := m.renderList()
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n\n")
b.WriteString(list)
b.WriteString("\n")
b.WriteString(m.renderPreview())
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
func (m *model) renderHeader() string {
th := m.th
sortName := "recently used"
switch m.sortM {
case store.SortFrequent:
sortName = "most used"
case store.SortAlpha:
sortName = "name"
}
left := th.Title.Render("dial") + th.Meta.Render(
fmt.Sprintf(" %d hosts · sorted by %s", len(m.rows), sortName))
if m.grouped {
left += th.Meta.Render(" · grouped")
}
// Orientation hint when the list is scrolled/windowed.
if m.winFirst >= 0 && (m.winFirst > 0 || m.winLast < len(m.view)-1) {
left += th.Meta.Render(fmt.Sprintf(" · showing %d%d of %d",
m.winFirst+1, m.winLast+1, len(m.view)))
}
if m.status != "" {
left += " " + th.Star.Render(m.status)
}
return m.truncate(" " + left)
}
// renderList builds the whole scrollable body as lines (section headers, host
// blocks, and spacers), then windows those lines so the cursor's host — and its
// section header, if any — stays visible. Line-based windowing lets grouped and
// flat modes share one code path and keeps the preview/footer from being pushed
// off-screen regardless of how many headers are on screen.
func (m *model) renderList() string {
th := m.th
m.winFirst, m.winLast = -1, -1
if len(m.view) == 0 {
msg := "no hosts in ~/.ssh/config"
if m.query != "" {
msg = "no hosts match \"" + m.query + "\""
}
return " " + th.Meta.Render(msg)
}
var lines []string
hostLine := make([]int, len(m.view)) // index of a host's first content line
headerLine := make([]int, len(m.view)) // index of the header line before it, or -1
for i := range m.view {
headerLine[i] = -1
if m.grouped {
if h, ok := m.headers[i]; ok {
if len(lines) > 0 {
lines = append(lines, "") // gap before a new section
}
headerLine[i] = len(lines)
lines = append(lines, m.truncate(m.renderSectionHeader(h)), "")
}
}
hostLine[i] = len(lines)
l1, l2 := m.renderHostLines(i)
lines = append(lines, m.truncate(l1), m.truncate(l2), "")
}
// Scroll so the cursor block (plus its header, if any) stays visible: start
// from the previous offset and nudge up or down only as needed.
budget := m.visibleListLines()
top := m.top
blockTop := hostLine[m.cursor]
if headerLine[m.cursor] >= 0 {
blockTop = headerLine[m.cursor]
}
if blockTop < top {
top = blockTop // scroll up to reveal the cursor (and its header)
}
blockBottom := hostLine[m.cursor] + 1 // the detail line
if blockBottom >= top+budget {
top = blockBottom - budget + 1 // scroll down to reveal the cursor
}
if maxTop := len(lines) - budget; top > maxTop {
top = maxTop
}
if top < 0 {
top = 0
}
m.top = top
end := top + budget
if end > len(lines) {
end = len(lines)
}
for i := range m.view {
if hostLine[i] >= top && hostLine[i] < end {
if m.winFirst == -1 {
m.winFirst = i
}
m.winLast = i
}
}
return strings.Join(lines[top:end], "\n")
}
// renderHostLines returns the two rendered lines for the host at view position i.
func (m *model) renderHostLines(i int) (string, string) {
th := m.th
r := m.rows[m.view[i]]
selected := i == m.cursor
marker := " "
if selected {
marker = th.Cursor.Render("▸ ")
}
numStr := th.Number.Render(fmt.Sprintf("%2d", i+1))
if selected {
numStr = th.NumberSel.Render(fmt.Sprintf("%2d", i+1))
}
star := " "
if r.fav {
star = th.Star.Render("★ ")
}
alias := th.Alias.Render(r.host.Alias)
if selected {
alias = th.AliasSel.Render(r.host.Alias)
}
line1 := fmt.Sprintf("%s%s %s%s", marker, numStr, star, alias)
if len(r.host.Tags) > 0 {
line1 += " " + m.renderPills(r.host.Tags)
}
dstyle := th.Detail
if selected {
dstyle = th.DetailSel
}
return line1, " " + dstyle.Render(summary(r.host))
}
// renderPills renders each tag in its own deterministic colour.
func (m *model) renderPills(tags []string) string {
parts := make([]string, len(tags))
for i, t := range tags {
parts[i] = m.th.TagStyle(t).Render(t)
}
return strings.Join(parts, " ")
}
// renderSectionHeader renders a grouped-mode section label, coloured to match
// the tag's pill (neutral for the Favourites/untagged sections).
func (m *model) renderSectionHeader(h sectionHead) string {
return " " + m.th.TagStyle(h.tag).Bold(true).Render("▊ "+h.title)
}
// summary builds a compact "user@hostname:port (via jump)" line for the list.
func summary(h sshconf.Host) string {
target := h.HostName
if target == "" {
target = h.Alias
}
s := target
if h.User != "" {
s = h.User + "@" + target
}
if h.Port != "" && h.Port != "22" {
s += ":" + h.Port
}
if h.ProxyJump != "" {
s += " via " + h.ProxyJump
}
return s
}
func (m *model) renderPreview() string {
th := m.th
if m.cursor < 0 || m.cursor >= len(m.view) {
return th.Preview.Render(th.Meta.Render("no selection"))
}
r := m.rows[m.view[m.cursor]]
h := r.host
rowsKV := [][2]string{
{"Host", h.Alias},
{"HostName", orDash(h.HostName)},
{"User", orDash(h.User)},
{"Port", orDash(defaultPort(h.Port))},
{"ProxyJump", orDash(h.ProxyJump)},
{"Last used", humanizeSince(r.last)},
}
var lines []string
for _, kv := range rowsKV {
lines = append(lines, fmt.Sprintf("%s %s",
th.PreviewK.Render(pad(kv[0], 10)), th.PreviewV.Render(kv[1])))
}
box := th.Preview.Width(m.previewWidth()).Render(strings.Join(lines, "\n"))
return box
}
func (m *model) previewWidth() int {
// Subtract the box border so the whole box fits the terminal.
w := m.width - 6
if w > 100 {
w = 100 // don't stretch the box uncomfortably wide
}
if w < 10 {
w = 10
}
return w
}
func (m *model) renderFooter() string {
th := m.th
if m.filtering {
bar := th.FilterBar.Render("/ " + m.query + "▌")
hint := th.Help.Render(" type to filter · enter connect · esc cancel")
return m.truncate(" " + bar + hint)
}
hints := []string{
"↑↓ move", "enter connect", "/ search", "s sort", "g group", "f fav", "q quit",
}
return m.truncate(" " + th.Help.Render(strings.Join(hints, " · ")))
}
// truncate clamps a rendered line to the terminal width to avoid wrapping,
// which would break the fixed 2-lines-per-row layout.
func (m *model) truncate(s string) string {
if m.width <= 0 {
return s
}
return lipgloss.NewStyle().MaxWidth(m.width).Render(s)
}
// humanizeSince renders a connection time as a compact relative phrase for the
// preview pane. A zero time means the host has never been connected to.
func humanizeSince(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < 0:
return "just now"
case d < time.Minute:
return "just now"
case d < time.Hour:
return plural(int(d.Minutes()), "minute")
case d < 24*time.Hour:
return plural(int(d.Hours()), "hour")
case d < 30*24*time.Hour:
return plural(int(d.Hours()/24), "day")
case d < 365*24*time.Hour:
return plural(int(d.Hours()/(24*30)), "month")
default:
return plural(int(d.Hours()/(24*365)), "year")
}
}
func plural(n int, unit string) string {
if n <= 1 {
return fmt.Sprintf("%d %s ago", n, unit)
}
return fmt.Sprintf("%d %ss ago", n, unit)
}
func orDash(s string) string {
if s == "" {
return "—"
}
return s
}
func defaultPort(p string) string {
if p == "" {
return "22"
}
return p
}
func pad(s string, n int) string {
if len(s) >= n {
return s
}
return s + strings.Repeat(" ", n-len(s))
}
+264
View File
@@ -0,0 +1,264 @@
// Package sshconf parses ~/.ssh/config (following Include directives) into a
// flat list of connectable Host entries. It is strictly read-only: nothing in
// this package ever writes to the ssh config tree.
//
// A small hand-written parser is used instead of a third-party library so that
// two things the ssh_config format does not natively support are available:
// - the "# tag: a, b" comment convention placed above a Host block, and
// - full control over Include expansion order for stable, fast startup.
package sshconf
import (
"bufio"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// Host is a single connectable ssh alias with the preview fields dial surfaces.
// It intentionally holds only what the picker needs; connecting is done by
// exec'ing `ssh <Alias>` so the real ssh client re-resolves everything.
type Host struct {
Alias string
HostName string
User string
Port string
ProxyJump string
Tags []string
Source string // config file this entry was declared in
}
// maxIncludeDepth guards against pathological or cyclic Include chains.
const maxIncludeDepth = 16
// DefaultConfigPath returns ~/.ssh/config for the current user.
func DefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".ssh", "config"), nil
}
// Parse reads the config at path, follows Include directives, and returns the
// concrete hosts in declaration order. A missing top-level config is not an
// error: it yields an empty list so a fresh machine still launches cleanly.
func Parse(path string) ([]Host, error) {
sshDir := filepath.Dir(path)
p := &parser{sshDir: sshDir, seen: map[string]bool{}}
if err := p.parseFile(path, 0); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out := make([]Host, len(p.hosts))
for i, h := range p.hosts {
out[i] = *h
}
return out, nil
}
type parser struct {
sshDir string
seen map[string]bool // include cycle guard, by absolute path
hosts []*Host // pointers so block updates stay valid across appends
}
func (p *parser) parseFile(path string, depth int) error {
if depth > maxIncludeDepth {
return nil
}
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
if p.seen[abs] {
return nil // already parsed; avoid include cycles
}
p.seen[abs] = true
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
return p.parseReader(f, path, depth)
}
func (p *parser) parseReader(r io.Reader, source string, depth int) error {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var pendingTags []string // tags from a "# tag:" comment awaiting the next Host
var block []*Host // all aliases in the current Host block
// applyKV sets a keyword/value pair on every host in the current block.
applyKV := func(keyword, value string) {
for _, h := range block {
switch strings.ToLower(keyword) {
case "hostname":
h.HostName = value
case "user":
h.User = value
case "port":
h.Port = value
case "proxyjump":
h.ProxyJump = value
}
}
}
for sc.Scan() {
raw := sc.Text()
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
if tags, ok := parseTagComment(line); ok {
pendingTags = append(pendingTags, tags...)
}
continue
}
keyword, value := splitKeyword(line)
if keyword == "" {
continue
}
switch strings.ToLower(keyword) {
case "host":
block = nil
for _, alias := range strings.Fields(value) {
if isPattern(alias) {
continue // wildcard/negated patterns aren't connect targets
}
h := &Host{Alias: alias, Source: source, Tags: append([]string(nil), pendingTags...)}
p.hosts = append(p.hosts, h)
block = append(block, h)
}
pendingTags = nil
case "include":
// Include is resolved relative to ~/.ssh and may contain globs.
for _, pat := range strings.Fields(value) {
for _, inc := range p.expandInclude(pat) {
_ = p.parseFile(inc, depth+1)
}
}
pendingTags = nil
case "match":
// Match blocks are conditional; dial does not evaluate conditions.
// End the current block so stray keywords don't attach to a host.
block = nil
pendingTags = nil
default:
if len(block) > 0 {
applyKV(keyword, value)
}
}
}
return sc.Err()
}
// expandInclude resolves an Include pattern to concrete file paths. Relative
// patterns are anchored at ~/.ssh, matching OpenSSH behaviour.
func (p *parser) expandInclude(pattern string) []string {
pattern = expandTilde(pattern)
if !filepath.IsAbs(pattern) {
pattern = filepath.Join(p.sshDir, pattern)
}
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return nil
}
sort.Strings(matches) // deterministic order across machines
// Filter out directories; only include regular files.
var files []string
for _, m := range matches {
if fi, err := os.Stat(m); err == nil && !fi.IsDir() {
files = append(files, m)
}
}
return files
}
// parseTagComment recognises the "# tag: prod, riedel" convention and returns
// the individual tags. It is lenient about spacing and the tag/tags spelling.
func parseTagComment(line string) ([]string, bool) {
body := strings.TrimSpace(strings.TrimPrefix(line, "#"))
lower := strings.ToLower(body)
var rest string
switch {
case strings.HasPrefix(lower, "tags:"):
rest = body[len("tags:"):]
case strings.HasPrefix(lower, "tag:"):
rest = body[len("tag:"):]
default:
return nil, false
}
var tags []string
for _, t := range strings.Split(rest, ",") {
if t = strings.TrimSpace(t); t != "" {
tags = append(tags, t)
}
}
return tags, len(tags) > 0
}
// splitKeyword splits an ssh_config line into keyword and value. ssh_config
// allows "Key Value" and "Key = Value"; both are handled here.
func splitKeyword(line string) (string, string) {
// Strip trailing inline comments only when clearly separated.
if i := strings.Index(line, " #"); i >= 0 {
// keep '#' inside values rare; safe to drop trailing comment
line = strings.TrimSpace(line[:i])
}
if eq := strings.IndexByte(line, '='); eq >= 0 && !strings.ContainsAny(line[:eq], " \t") {
return strings.TrimSpace(line[:eq]), unquote(strings.TrimSpace(line[eq+1:]))
}
fields := strings.Fields(line)
if len(fields) == 0 {
return "", ""
}
if len(fields) == 1 {
return fields[0], ""
}
// Support "Key = Value" where '=' is its own token.
if fields[1] == "=" {
return fields[0], unquote(strings.TrimSpace(strings.Join(fields[2:], " ")))
}
return fields[0], unquote(strings.TrimSpace(strings.Join(fields[1:], " ")))
}
func unquote(s string) string {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
// isPattern reports whether an alias is a wildcard/negation pattern rather than
// a concrete connectable host.
func isPattern(alias string) bool {
return strings.ContainsAny(alias, "*?!")
}
func expandTilde(path string) string {
if path == "~" || strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
if path == "~" {
return home
}
return filepath.Join(home, path[2:])
}
}
return path
}
+106
View File
@@ -0,0 +1,106 @@
package sshconf
import (
"os"
"path/filepath"
"testing"
)
func TestParseWithIncludeAndTags(t *testing.T) {
dir := t.TempDir()
incDir := filepath.Join(dir, "conf.d")
if err := os.MkdirAll(incDir, 0o755); err != nil {
t.Fatal(err)
}
main := `# global defaults
Host *
ServerAliveInterval 60
# tag: prod, riedel
Host web1 web2
HostName 10.0.0.5
User deploy
Port 2222
ProxyJump bastion
Include conf.d/*.conf
Host bastion
HostName bastion.example.com
User admin
`
inc := `# tag: staging
Host stage
HostName stage.example.com
User dev = ignored
Host = eqform
HostName eq.example.com
`
if err := os.WriteFile(filepath.Join(dir, "config"), []byte(main), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(incDir, "a.conf"), []byte(inc), 0o644); err != nil {
t.Fatal(err)
}
hosts, err := Parse(filepath.Join(dir, "config"))
if err != nil {
t.Fatal(err)
}
byAlias := map[string]Host{}
for _, h := range hosts {
byAlias[h.Alias] = h
}
// Wildcard "Host *" must not appear as a connectable target.
if _, ok := byAlias["*"]; ok {
t.Error("wildcard host * should be skipped")
}
// Multiple aliases in one block share settings and tags.
for _, a := range []string{"web1", "web2"} {
h, ok := byAlias[a]
if !ok {
t.Fatalf("missing host %s", a)
}
if h.HostName != "10.0.0.5" || h.User != "deploy" || h.Port != "2222" || h.ProxyJump != "bastion" {
t.Errorf("%s wrong fields: %+v", a, h)
}
if len(h.Tags) != 2 || h.Tags[0] != "prod" || h.Tags[1] != "riedel" {
t.Errorf("%s wrong tags: %v", a, h.Tags)
}
}
// Included file's host and its tag.
stage, ok := byAlias["stage"]
if !ok {
t.Fatal("included host 'stage' missing")
}
if len(stage.Tags) != 1 || stage.Tags[0] != "staging" {
t.Errorf("stage tags wrong: %v", stage.Tags)
}
// "Host = eqform" equals-form should parse the alias.
if _, ok := byAlias["eqform"]; !ok {
t.Error("equals-form Host 'eqform' missing")
}
// Ordering: web1 before included stage before bastion.
order := []string{}
for _, h := range hosts {
order = append(order, h.Alias)
}
t.Logf("order: %v", order)
}
func TestParseMissingConfig(t *testing.T) {
hosts, err := Parse(filepath.Join(t.TempDir(), "does-not-exist"))
if err != nil {
t.Fatalf("missing config should not error: %v", err)
}
if hosts != nil {
t.Errorf("expected nil hosts, got %v", hosts)
}
}
+298
View File
@@ -0,0 +1,298 @@
// Package store persists dial's app data under ~/.ssh/.dial/ so it rides along
// on the user's existing Syncthing sync of the .ssh tree.
//
// Two files are used:
// - settings.json — yubikey on/off + slot, theme, sort preference, and the
// *hashed* backup recovery code (never any secret material).
// - data.json — recents/frequency stats and favorites.
//
// Because multiple machines may write concurrently through Syncthing, the
// contract here is deliberately forgiving: last-write-wins on save, and every
// load tolerates a missing, partial, or corrupt file by returning defaults
// rather than failing. Writes are atomic (temp file + rename) so a reader on
// another machine never observes a half-written file from a local write.
package store
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
// SortMode controls the resting order of the host list.
type SortMode string
const (
SortRecent SortMode = "recent" // most-recently-used first
SortFrequent SortMode = "frequent" // most-frequently-used first
SortAlpha SortMode = "alpha" // alphabetical by alias
)
// Next returns the following sort mode in the toggle cycle
// recent → frequent → alpha → recent.
func (s SortMode) Next() SortMode {
switch s {
case SortRecent:
return SortFrequent
case SortFrequent:
return SortAlpha
default:
return SortRecent
}
}
// Valid reports whether s is a known sort mode.
func (s SortMode) Valid() bool {
return s == SortRecent || s == SortFrequent || s == SortAlpha
}
// Settings holds user preferences and the hashed recovery code. It contains
// nothing secret: the YubiKey secret never leaves the key in HMAC challenge-
// response mode, and the recovery code is stored only as a salted hash.
type Settings struct {
YubiKeyEnabled bool `json:"yubikey_enabled"`
YubiKeySlot int `json:"yubikey_slot"` // 1 or 2 (default 2)
Theme string `json:"theme"` // "dark" (default) | "light"
Sort SortMode `json:"sort"`
GroupByTag bool `json:"group_by_tag"` // section the list by tag
// YubiKey holds the non-secret challenge and the salted hash of its expected
// response, used to verify the physical key at launch. It is empty until the
// gate is enrolled.
YubiKey YubiKeyConfig `json:"yubikey,omitempty"`
// Recovery is the salted hash of the one-time backup code. Empty when the
// gate is disabled or no code is currently active.
Recovery RecoveryHash `json:"recovery,omitempty"`
}
// YubiKeyConfig captures what dial needs to verify a YubiKey without storing any
// secret. Challenge is random and non-secret; ResponseHash is a salted SHA-256
// of the key's HMAC response to that challenge, which reveals nothing usable
// and cannot reproduce the response without the physical key.
type YubiKeyConfig struct {
Challenge string `json:"challenge"` // hex-encoded random challenge
ResponseSalt string `json:"response_salt"` // hex-encoded random salt
ResponseHash string `json:"response_hash"` // hex sha256(salt || response)
}
// IsEnrolled reports whether a YubiKey verification record is present.
func (y YubiKeyConfig) IsEnrolled() bool {
return y.Challenge != "" && y.ResponseHash != "" && y.ResponseSalt != ""
}
// RecoveryHash is the persisted, non-reversible form of a backup recovery code.
type RecoveryHash struct {
Salt string `json:"salt"` // hex-encoded random salt
Hash string `json:"hash"` // hex-encoded sha256(salt || code)
}
// IsSet reports whether an active recovery code hash is present.
func (r RecoveryHash) IsSet() bool { return r.Hash != "" && r.Salt != "" }
// DefaultSettings returns the settings used when none are stored or the stored
// file is unreadable/corrupt.
func DefaultSettings() Settings {
return Settings{
YubiKeyEnabled: false,
YubiKeySlot: 2,
Theme: "dark",
Sort: SortRecent,
}
}
// HostStat records usage of a single host alias for MRU/MFU sorting.
type HostStat struct {
Count int `json:"count"`
LastUsed time.Time `json:"last_used"`
}
// Data holds mutable usage state that changes on nearly every run.
type Data struct {
Stats map[string]*HostStat `json:"stats"`
Favorites []string `json:"favorites"`
// LastUnlock records the most recent successful gate unlock. Unused by the
// every-launch gate policy but persisted so an idle-timeout policy could be
// added later without a data migration.
LastUnlock time.Time `json:"last_unlock,omitempty"`
}
// DefaultData returns empty usage data.
func DefaultData() Data {
return Data{Stats: map[string]*HostStat{}}
}
// mu serialises local writes so two goroutines in this process can't interleave
// temp files. It does nothing about cross-machine writes (Syncthing) — that is
// intentionally last-write-wins.
var mu sync.Mutex
// Dir returns ~/.ssh/.dial, creating it (0700) if necessary.
func Dir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
d := filepath.Join(home, ".ssh", ".dial")
if err := os.MkdirAll(d, 0o700); err != nil {
return "", err
}
return d, nil
}
func settingsPath() (string, error) {
d, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(d, "settings.json"), nil
}
func dataPath() (string, error) {
d, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(d, "data.json"), nil
}
// LoadSettings reads settings, falling back to defaults on any error including a
// partial/corrupt file mid-sync. It never returns an error for that reason so
// the app always starts.
func LoadSettings() Settings {
def := DefaultSettings()
p, err := settingsPath()
if err != nil {
return def
}
b, err := os.ReadFile(p)
if err != nil {
return def
}
var s Settings
if err := json.Unmarshal(b, &s); err != nil {
return def // partial write / corruption: fall back
}
// Repair any nonsensical values that a bad/partial write could leave.
if s.YubiKeySlot != 1 && s.YubiKeySlot != 2 {
s.YubiKeySlot = def.YubiKeySlot
}
if s.Theme == "" {
s.Theme = def.Theme
}
if !s.Sort.Valid() {
s.Sort = def.Sort
}
return s
}
// SaveSettings writes settings atomically. Errors are returned because a failed
// settings write (e.g. toggling the gate) is meaningful to the caller.
func SaveSettings(s Settings) error {
p, err := settingsPath()
if err != nil {
return err
}
return writeAtomic(p, s)
}
// LoadData reads usage data, falling back to empty defaults on any error.
func LoadData() Data {
def := DefaultData()
p, err := dataPath()
if err != nil {
return def
}
b, err := os.ReadFile(p)
if err != nil {
return def
}
var d Data
if err := json.Unmarshal(b, &d); err != nil {
return def
}
if d.Stats == nil {
d.Stats = map[string]*HostStat{}
}
return d
}
// SaveData writes usage data atomically. A failed data write is non-fatal to
// the user's flow (they still connected), so callers may choose to ignore it.
func SaveData(d Data) error {
p, err := dataPath()
if err != nil {
return err
}
if d.Stats == nil {
d.Stats = map[string]*HostStat{}
}
return writeAtomic(p, d)
}
// Record bumps the usage stats for an alias. It loads, mutates, and saves so a
// connect can persist in one call; the save error is returned for logging but
// is safe to ignore.
func Record(alias string) error {
d := LoadData()
st := d.Stats[alias]
if st == nil {
st = &HostStat{}
d.Stats[alias] = st
}
st.Count++
st.LastUsed = time.Now()
return SaveData(d)
}
// ToggleFavorite adds or removes an alias from favorites and persists.
func ToggleFavorite(alias string) (bool, error) {
d := LoadData()
for i, f := range d.Favorites {
if f == alias {
d.Favorites = append(d.Favorites[:i], d.Favorites[i+1:]...)
return false, SaveData(d)
}
}
d.Favorites = append(d.Favorites, alias)
return true, SaveData(d)
}
// writeAtomic marshals v to JSON and writes it to path via a temp file + rename
// so concurrent readers never see a partial file.
func writeAtomic(path string, v any) error {
mu.Lock()
defer mu.Unlock()
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
// Best-effort cleanup if we bail before the rename.
defer os.Remove(tmpName)
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, 0o600); err != nil {
return err
}
return os.Rename(tmpName, path)
}
+82
View File
@@ -0,0 +1,82 @@
package store
import (
"os"
"path/filepath"
"testing"
)
// withHome points UserHomeDir at a temp dir for the duration of a test.
func withHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
// Ensure ~/.ssh exists as the store nests under it.
if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil {
t.Fatal(err)
}
return home
}
func TestSettingsRoundTrip(t *testing.T) {
withHome(t)
s := DefaultSettings()
s.YubiKeyEnabled = true
s.Sort = SortFrequent
if err := SaveSettings(s); err != nil {
t.Fatal(err)
}
got := LoadSettings()
if !got.YubiKeyEnabled || got.Sort != SortFrequent {
t.Errorf("round trip mismatch: %+v", got)
}
}
func TestLoadCorruptFallsBackToDefaults(t *testing.T) {
withHome(t)
d, _ := Dir()
// Simulate a mid-Syncthing partial write.
if err := os.WriteFile(filepath.Join(d, "settings.json"), []byte(`{"yubikey_enabled": tr`), 0o600); err != nil {
t.Fatal(err)
}
got := LoadSettings()
def := DefaultSettings()
if got != def {
t.Errorf("corrupt settings should yield defaults, got %+v", got)
}
}
func TestRecordAndSort(t *testing.T) {
withHome(t)
if err := Record("web1"); err != nil {
t.Fatal(err)
}
if err := Record("web1"); err != nil {
t.Fatal(err)
}
if err := Record("db"); err != nil {
t.Fatal(err)
}
d := LoadData()
if d.Stats["web1"].Count != 2 {
t.Errorf("web1 count = %d, want 2", d.Stats["web1"].Count)
}
if d.Stats["db"].Count != 1 {
t.Errorf("db count = %d, want 1", d.Stats["db"].Count)
}
}
func TestToggleFavorite(t *testing.T) {
withHome(t)
on, err := ToggleFavorite("web1")
if err != nil || !on {
t.Fatalf("expected favorite on, got %v %v", on, err)
}
off, err := ToggleFavorite("web1")
if err != nil || off {
t.Fatalf("expected favorite off, got %v %v", off, err)
}
if len(LoadData().Favorites) != 0 {
t.Error("favorites should be empty after toggle off")
}
}
+105
View File
@@ -0,0 +1,105 @@
// Package theme centralises dial's visual styling. The brief makes low-light
// legibility a hard requirement: generous spacing, high contrast, and no dense
// dim text. These styles encode that — the "muted" tones are still clearly
// readable, not the near-invisible grey common in dense pickers.
package theme
import (
"hash/fnv"
"github.com/charmbracelet/lipgloss"
)
// Theme holds the styles used across the picker.
type Theme struct {
Title lipgloss.Style
Meta lipgloss.Style // header meta / hints
Number lipgloss.Style // list index
NumberSel lipgloss.Style
Alias lipgloss.Style // host alias, resting
AliasSel lipgloss.Style // host alias, highlighted
Detail lipgloss.Style // secondary line (hostname/user)
DetailSel lipgloss.Style
Tag lipgloss.Style
Star lipgloss.Style
Cursor lipgloss.Style // the selection bar/marker
Preview lipgloss.Style // preview box border
PreviewK lipgloss.Style // preview key column
PreviewV lipgloss.Style // preview value column
FilterBar lipgloss.Style
Help lipgloss.Style
Warn lipgloss.Style
// tagPalette is a set of distinct high-contrast hues used to colour tag
// pills and tag section headers, assigned deterministically per tag name.
tagPalette []lipgloss.AdaptiveColor
base lipgloss.Style
}
// TagStyle returns the pill style for a tag, colour chosen deterministically by
// the tag name so the same tag is always the same colour. An empty tag name
// (used for the Favourites/untagged sections) gets a neutral style.
func (t Theme) TagStyle(tag string) lipgloss.Style {
if tag == "" {
return t.Meta
}
h := fnv.New32a()
_, _ = h.Write([]byte(tag))
c := t.tagPalette[int(h.Sum32())%len(t.tagPalette)]
return t.base.Foreground(c)
}
// adaptive picks a colour for dark vs light backgrounds.
func adaptive(dark, light string) lipgloss.AdaptiveColor {
return lipgloss.AdaptiveColor{Dark: dark, Light: light}
}
// New returns the theme for the given name ("dark" or "light"). Both are built
// from the same high-contrast palette; lipgloss adapts per background so a
// single definition stays readable either way.
func New(name string) Theme {
// High-contrast, deliberately bright accents for low-light legibility.
accent := adaptive("#7DD3FC", "#0369A1") // sky — highlighted alias
accent2 := adaptive("#FDE047", "#A16207") // amber — numbers/stars
fg := adaptive("#F1F5F9", "#0F172A") // primary text (near-white / near-black)
muted := adaptive("#CBD5E1", "#334155") // secondary text — still clearly readable
faint := adaptive("#94A3B8", "#475569") // hints; the dimmest we allow
tag := adaptive("#86EFAC", "#15803D") // green tags
warn := adaptive("#FCA5A5", "#B91C1C")
base := lipgloss.NewStyle()
// Distinct, high-contrast hues for tag pills (dark / light variants).
tagPalette := []lipgloss.AdaptiveColor{
adaptive("#7DD3FC", "#0369A1"), // sky
adaptive("#86EFAC", "#15803D"), // green
adaptive("#FDBA74", "#C2410C"), // orange
adaptive("#F9A8D4", "#BE185D"), // pink
adaptive("#C4B5FD", "#6D28D9"), // violet
adaptive("#5EEAD4", "#0F766E"), // teal
adaptive("#FDE047", "#A16207"), // amber
adaptive("#FCA5A5", "#B91C1C"), // red
}
return Theme{
base: base,
tagPalette: tagPalette,
Title: base.Bold(true).Foreground(fg),
Meta: base.Foreground(faint),
Number: base.Foreground(faint),
NumberSel: base.Bold(true).Foreground(accent2),
Alias: base.Foreground(fg),
AliasSel: base.Bold(true).Foreground(accent),
Detail: base.Foreground(muted),
DetailSel: base.Foreground(muted),
Tag: base.Foreground(tag),
Star: base.Foreground(accent2),
Cursor: base.Bold(true).Foreground(accent),
Preview: base.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(faint).Padding(0, 2),
PreviewK: base.Foreground(faint),
PreviewV: base.Foreground(fg),
FilterBar: base.Bold(true).Foreground(accent2),
Help: base.Foreground(faint),
Warn: base.Bold(true).Foreground(warn),
}
}
+95
View File
@@ -0,0 +1,95 @@
package yubikey
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/hex"
"strings"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// Recovery code format: 6 dash-separated groups of 5 base32 characters
// (XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX), i.e. 30 chars ≈ 150 bits of entropy.
// It is shown to the user exactly once at enrollment and stored only as a
// salted hash. It is single-use: after a successful recovery unlock the caller
// invalidates it and issues a fresh one.
const (
recoveryGroups = 6
recoveryGroupLen = 5
recoveryChars = recoveryGroups * recoveryGroupLen // 30
recoveryRawBytes = 19 // 19 bytes -> 31 base32 chars, sliced to 30
)
// base32NoPad uses the standard RFC 4648 alphabet without padding.
var base32NoPad = base32.StdEncoding.WithPadding(base32.NoPadding)
// GenerateRecoveryCode returns a fresh human-formatted recovery code together
// with its salted hash for storage. The plaintext is returned only here and is
// expected to be displayed once and then discarded by the caller.
func GenerateRecoveryCode() (code string, h store.RecoveryHash, err error) {
raw := make([]byte, recoveryRawBytes)
if _, err = rand.Read(raw); err != nil {
return "", store.RecoveryHash{}, err
}
enc := base32NoPad.EncodeToString(raw)[:recoveryChars]
var b strings.Builder
for i := 0; i < recoveryGroups; i++ {
if i > 0 {
b.WriteByte('-')
}
b.WriteString(enc[i*recoveryGroupLen : (i+1)*recoveryGroupLen])
}
code = b.String()
salt := make([]byte, 16)
if _, err = rand.Read(salt); err != nil {
return "", store.RecoveryHash{}, err
}
h = store.RecoveryHash{
Salt: hex.EncodeToString(salt),
Hash: hex.EncodeToString(hashCode(salt, normalizeCode(code))),
}
return code, h, nil
}
// VerifyRecoveryCode reports whether input matches the stored recovery hash.
// Input is normalised (uppercased, separators/whitespace removed) before
// comparison so the user can type it with or without dashes.
func VerifyRecoveryCode(input string, h store.RecoveryHash) bool {
if !h.IsSet() {
return false
}
salt, err := hex.DecodeString(h.Salt)
if err != nil {
return false
}
want, err := hex.DecodeString(h.Hash)
if err != nil {
return false
}
got := hashCode(salt, normalizeCode(input))
return subtle.ConstantTimeCompare(got, want) == 1
}
// normalizeCode strips separators/whitespace and uppercases so that "abc de"
// and "ABCDE" compare equal.
func normalizeCode(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
if (r >= 'A' && r <= 'Z') || (r >= '2' && r <= '7') {
b.WriteRune(r)
}
}
return b.String()
}
func hashCode(salt []byte, normalized string) []byte {
h := sha256.New()
h.Write(salt)
h.Write([]byte(normalized))
return h.Sum(nil)
}
+161
View File
@@ -0,0 +1,161 @@
// Package yubikey implements dial's optional launch gate. It performs an
// offline HMAC-SHA1 challenge-response against a YubiKey by shelling out to
// Yubico's official `ykman` CLI (slot configurable, default 2), and manages a
// single-use backup recovery code so the user can't be permanently locked out.
//
// Per the brief, this feature intentionally depends on `ykman` being installed
// separately — it trades the "single static binary" goal for leaning on
// Yubico's own well-tested tool. dial checks for `ykman` on PATH and reports a
// clear error if it is missing.
//
// Nothing secret is persisted: enrollment stores only a random challenge and a
// salted hash of the key's response. The YubiKey secret never leaves the key.
package yubikey
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"os/exec"
"strings"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// ErrYkmanMissing is returned when the gate is enabled but `ykman` is not on
// PATH. Callers surface this as an actionable message.
var ErrYkmanMissing = errors.New("ykman not found on PATH: install Yubico's ykman CLI to use the YubiKey gate (https://developers.yubico.com/yubikey-manager/)")
// ErrKeyMismatch means the connected key produced a response that does not
// match the enrolled one (wrong key, wrong slot, or no key present).
var ErrKeyMismatch = errors.New("YubiKey response did not match the enrolled key")
// runYkman executes ykman and returns stdout. It is a package variable so tests
// can stub the external call without a physical key.
var runYkman = func(args ...string) ([]byte, error) {
cmd := exec.Command("ykman", args...)
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errBuf.String())
if msg != "" {
return nil, fmt.Errorf("ykman: %s", msg)
}
return nil, err
}
return out.Bytes(), nil
}
// Available reports whether the ykman CLI is installed and on PATH.
func Available() error {
if _, err := exec.LookPath("ykman"); err != nil {
return ErrYkmanMissing
}
return nil
}
// challenge runs an HMAC-SHA1 challenge-response against the given slot. The
// challenge is passed as hex; ykman blocks for a physical touch if the slot was
// programmed to require one. The response is returned as raw bytes.
func challenge(slot int, chal []byte) ([]byte, error) {
if slot != 1 && slot != 2 {
return nil, fmt.Errorf("invalid slot %d (must be 1 or 2)", slot)
}
out, err := runYkman("otp", "calculate", fmt.Sprintf("%d", slot), hex.EncodeToString(chal))
if err != nil {
return nil, err
}
resp, err := parseResponse(out)
if err != nil {
return nil, err
}
return resp, nil
}
// parseResponse extracts the hex response from ykman's stdout, tolerating extra
// whitespace or trailing lines.
func parseResponse(out []byte) ([]byte, error) {
var last string
sc := bufio.NewScanner(bytes.NewReader(out))
for sc.Scan() {
if t := strings.TrimSpace(sc.Text()); t != "" {
last = t
}
}
last = strings.TrimSpace(last)
if last == "" {
return nil, errors.New("ykman returned an empty response")
}
b, err := hex.DecodeString(last)
if err != nil {
return nil, fmt.Errorf("unexpected ykman output %q: %w", last, err)
}
return b, nil
}
// Enroll performs a challenge-response now (prompting a touch) and returns a
// YubiKeyConfig that records the challenge and a salted hash of the response.
// This is called when the user turns the gate on.
func Enroll(slot int) (store.YubiKeyConfig, error) {
chal := make([]byte, 20) // 160-bit challenge, matching HMAC-SHA1's block use
if _, err := rand.Read(chal); err != nil {
return store.YubiKeyConfig{}, err
}
resp, err := challenge(slot, chal)
if err != nil {
return store.YubiKeyConfig{}, err
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return store.YubiKeyConfig{}, err
}
return store.YubiKeyConfig{
Challenge: hex.EncodeToString(chal),
ResponseSalt: hex.EncodeToString(salt),
ResponseHash: hex.EncodeToString(hashResponse(salt, resp)),
}, nil
}
// Verify re-runs the enrolled challenge and checks the response hash matches.
// It returns ErrKeyMismatch if the physical key does not produce the expected
// response. A touch is required if the slot demands one.
func Verify(cfg store.YubiKeyConfig, slot int) error {
if !cfg.IsEnrolled() {
return errors.New("no YubiKey enrolled")
}
chal, err := hex.DecodeString(cfg.Challenge)
if err != nil {
return fmt.Errorf("corrupt stored challenge: %w", err)
}
salt, err := hex.DecodeString(cfg.ResponseSalt)
if err != nil {
return fmt.Errorf("corrupt stored salt: %w", err)
}
want, err := hex.DecodeString(cfg.ResponseHash)
if err != nil {
return fmt.Errorf("corrupt stored hash: %w", err)
}
resp, err := challenge(slot, chal)
if err != nil {
return err
}
got := hashResponse(salt, resp)
if subtle.ConstantTimeCompare(got, want) != 1 {
return ErrKeyMismatch
}
return nil
}
// hashResponse computes sha256(salt || response).
func hashResponse(salt, resp []byte) []byte {
h := sha256.New()
h.Write(salt)
h.Write(resp)
return h.Sum(nil)
}
+103
View File
@@ -0,0 +1,103 @@
package yubikey
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"strings"
"testing"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// fakeKey simulates a YubiKey slot programmed with a fixed secret by computing
// HMAC-SHA1 over the challenge, mirroring ykman's challenge-response output.
func fakeKey(t *testing.T, secret []byte) func(args ...string) ([]byte, error) {
t.Helper()
return func(args ...string) ([]byte, error) {
// args: otp calculate <slot> <hexchallenge>
if len(args) < 4 {
t.Fatalf("unexpected ykman args: %v", args)
}
chal, err := hex.DecodeString(args[3])
if err != nil {
t.Fatalf("bad challenge hex: %v", err)
}
m := hmac.New(sha1.New, secret)
m.Write(chal)
return []byte(hex.EncodeToString(m.Sum(nil)) + "\n"), nil
}
}
func TestEnrollThenVerifySucceeds(t *testing.T) {
orig := runYkman
defer func() { runYkman = orig }()
runYkman = fakeKey(t, []byte("secret-slot-2"))
cfg, err := Enroll(2)
if err != nil {
t.Fatal(err)
}
if !cfg.IsEnrolled() {
t.Fatal("config should be enrolled")
}
if err := Verify(cfg, 2); err != nil {
t.Errorf("verify with same key should succeed: %v", err)
}
}
func TestVerifyWrongKeyFails(t *testing.T) {
orig := runYkman
defer func() { runYkman = orig }()
runYkman = fakeKey(t, []byte("correct-secret"))
cfg, err := Enroll(2)
if err != nil {
t.Fatal(err)
}
// Now a different physical key (different secret) is present.
runYkman = fakeKey(t, []byte("attacker-secret"))
if err := Verify(cfg, 2); err != ErrKeyMismatch {
t.Errorf("expected ErrKeyMismatch, got %v", err)
}
}
func TestInvalidSlot(t *testing.T) {
if _, err := Enroll(3); err == nil {
t.Error("slot 3 should be rejected")
}
}
func TestRecoveryCodeRoundTrip(t *testing.T) {
code, h, err := GenerateRecoveryCode()
if err != nil {
t.Fatal(err)
}
// Format: 6 groups of 5 chars separated by dashes.
parts := strings.Split(code, "-")
if len(parts) != recoveryGroups {
t.Fatalf("expected %d groups, got %d (%q)", recoveryGroups, len(parts), code)
}
for _, p := range parts {
if len(p) != recoveryGroupLen {
t.Errorf("group %q wrong length", p)
}
}
if !VerifyRecoveryCode(code, h) {
t.Error("exact code should verify")
}
// With separators stripped / lowercased it should still verify.
if !VerifyRecoveryCode(strings.ToLower(strings.ReplaceAll(code, "-", " ")), h) {
t.Error("normalised code should verify")
}
if VerifyRecoveryCode("WRONG-WRONG-WRONG-WRONG-WRONG-WRONG", h) {
t.Error("wrong code should not verify")
}
}
func TestVerifyRecoveryUnsetHash(t *testing.T) {
if VerifyRecoveryCode("anything", store.RecoveryHash{}) {
t.Error("empty hash should never verify")
}
}