Files
kawaandClaude Sonnet 5 34e18987a0 Rename product to dockmv (container, image, module, env vars)
Container/image/service name, Go module path, CLI binary name, and
DOCKER_MIGRATE_* env vars still used the old working name; the project
is branded DockMV everywhere else (README, logo, Gitea repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:56:26 +02:00

210 lines
5.0 KiB
Go

// 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/dockmv/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)
}