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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user