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>
210 lines
5.0 KiB
Go
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)
|
|
}
|