// Package store persists target host connections between runs. package store import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "sync" "github.com/arescom/docker-migrate/internal/sshx" ) // ErrNotFound is returned for an unknown connection id. var ErrNotFound = errors.New("connection not found") // Connections is a small JSON-backed collection of target hosts. // // Secrets are only written when the operator opts in per connection. The file // is created with owner-only permissions either way. type Connections struct { path string mu sync.RWMutex // items holds the persisted form. items map[string]sshx.Config // secrets holds credentials for connections that opted out of persistence, // so they survive for the lifetime of the process but never hit disk. secrets map[string]secret } type secret struct { Password string PrivateKey string Passphrase string } // NewConnections loads (or creates) the connection file at path. func NewConnections(path string) (*Connections, error) { c := &Connections{path: path, items: map[string]sshx.Config{}, secrets: map[string]secret{}} if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("create data directory: %w", err) } b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return c, nil } if err != nil { return nil, fmt.Errorf("read connections: %w", err) } var list []sshx.Config if err := json.Unmarshal(b, &list); err != nil { return nil, fmt.Errorf("parse connections file %s: %w", path, err) } for _, cfg := range list { c.items[cfg.ID] = cfg } return c, nil } // List returns every connection with secrets stripped, newest name order. func (c *Connections) List() []sshx.Config { c.mu.RLock() defer c.mu.RUnlock() out := make([]sshx.Config, 0, len(c.items)) for _, cfg := range c.items { out = append(out, redact(cfg)) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // Get returns a connection ready to dial, with secrets filled back in. func (c *Connections) Get(id string) (sshx.Config, error) { c.mu.RLock() defer c.mu.RUnlock() cfg, ok := c.items[id] if !ok { return sshx.Config{}, ErrNotFound } if s, ok := c.secrets[id]; ok { if cfg.Password == "" { cfg.Password = s.Password } if cfg.PrivateKey == "" { cfg.PrivateKey = s.PrivateKey } if cfg.Passphrase == "" { cfg.Passphrase = s.Passphrase } } return cfg, nil } // Save inserts or updates a connection and returns the stored, redacted form. // // When SaveSecrets is false the credentials are kept in memory only; an update // that omits credentials keeps whatever was already held, so the UI can edit a // connection without re-entering a password. func (c *Connections) Save(cfg sshx.Config) (sshx.Config, error) { if cfg.Host == "" { return sshx.Config{}, errors.New("host is required") } if cfg.User == "" { return sshx.Config{}, errors.New("user is required") } if cfg.Port == 0 { cfg.Port = 22 } if cfg.Name == "" { cfg.Name = cfg.Host } c.mu.Lock() defer c.mu.Unlock() if cfg.ID == "" { cfg.ID = newID() } prev, existed := c.items[cfg.ID] prevSecret := c.secrets[cfg.ID] // Carry forward credentials the caller did not resend. if cfg.Password == "" { cfg.Password = firstNonEmpty(prev.Password, prevSecret.Password) } if cfg.PrivateKey == "" { cfg.PrivateKey = firstNonEmpty(prev.PrivateKey, prevSecret.PrivateKey) } if cfg.Passphrase == "" { cfg.Passphrase = firstNonEmpty(prev.Passphrase, prevSecret.Passphrase) } _ = existed if cfg.SaveSecrets { delete(c.secrets, cfg.ID) c.items[cfg.ID] = cfg } else { c.secrets[cfg.ID] = secret{ Password: cfg.Password, PrivateKey: cfg.PrivateKey, Passphrase: cfg.Passphrase, } c.items[cfg.ID] = redact(cfg) } if err := c.flush(); err != nil { return sshx.Config{}, err } return redact(c.items[cfg.ID]), nil } // Delete removes a connection. func (c *Connections) Delete(id string) error { c.mu.Lock() defer c.mu.Unlock() if _, ok := c.items[id]; !ok { return ErrNotFound } delete(c.items, id) delete(c.secrets, id) return c.flush() } // flush writes the file. The caller must hold the write lock. func (c *Connections) flush() error { list := make([]sshx.Config, 0, len(c.items)) for _, cfg := range c.items { list = append(list, cfg) } sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) b, err := json.MarshalIndent(list, "", " ") if err != nil { return err } tmp := c.path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return fmt.Errorf("write connections: %w", err) } if err := os.Rename(tmp, c.path); err != nil { return fmt.Errorf("replace connections file: %w", err) } return nil } func redact(cfg sshx.Config) sshx.Config { cfg.Password = "" cfg.PrivateKey = "" cfg.Passphrase = "" return cfg } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } func newID() string { b := make([]byte, 6) _, _ = rand.Read(b) return hex.EncodeToString(b) }