Initial commit: dial — interactive SSH host picker
A fast, read-only ~/.ssh/config picker that exec's the system ssh client. Includes: static high-contrast picker with recency/frequency/name sort and tag grouping, `dial keygen` (native ed25519/RSA + optional config entry), and an optional offline YubiKey launch gate with one-time recovery codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
@@ -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
|
||||||
@@ -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).
|
||||||
@@ -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`).
|
||||||
@@ -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" }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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`).
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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) }
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 X–Y 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user