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,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