package store import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "github.com/arescom/dockmv/internal/sshx" ) // SourceKind says how a source daemon is reached. type SourceKind string const ( // SourceLocal is the daemon this process talks to by default: the socket in // DOCKER_HOST, or whatever --docker-host was given. SourceLocal SourceKind = "local" // SourceDocker is an explicit daemon address, e.g. tcp://10.0.0.5:2375. SourceDocker SourceKind = "docker" // SourceSSH is a remote daemon reached over SSH, driven through the remote // host's own docker CLI. SourceSSH SourceKind = "ssh" ) // LocalSourceID identifies the built-in local source. It is always listed, and // cannot be edited or deleted. const LocalSourceID = "local" // Source is one place containers can be read from. type Source struct { ID string `json:"id"` Name string `json:"name"` Kind SourceKind `json:"kind"` // DockerHost is the daemon address for SourceDocker, and the address the // local source resolved to for SourceLocal (read-only in that case). DockerHost string `json:"dockerHost,omitempty"` // SSH describes the remote host for SourceSSH. SSH *sshx.Config `json:"ssh,omitempty"` } // Sources is a JSON-backed list of source daemons plus the one currently // selected, so a restart comes back to the host the operator was working on. // // Like Connections, SSH credentials only reach the file when the operator ticks // "remember"; otherwise they live in memory for this process only. type Sources struct { path string local Source mu sync.RWMutex items map[string]Source secrets map[string]secret selected string } // sourcesFile is the on-disk shape. type sourcesFile struct { Selected string `json:"selected,omitempty"` Sources []Source `json:"sources"` } // NewSources loads (or creates) the source file at path. local describes the // built-in local source, which is not persisted. func NewSources(path string, local Source) (*Sources, error) { local.ID = LocalSourceID local.Kind = SourceLocal if local.Name == "" { local.Name = "this host" } s := &Sources{ path: path, local: local, items: map[string]Source{}, secrets: map[string]secret{}, selected: LocalSourceID, } 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 s, nil } if err != nil { return nil, fmt.Errorf("read sources: %w", err) } var f sourcesFile if err := json.Unmarshal(b, &f); err != nil { return nil, fmt.Errorf("parse sources file %s: %w", path, err) } for _, src := range f.Sources { if src.ID == "" || src.ID == LocalSourceID { continue } s.items[src.ID] = src } if f.Selected != "" { if _, ok := s.items[f.Selected]; ok || f.Selected == LocalSourceID { s.selected = f.Selected } } return s, nil } // Local returns the built-in local source. func (s *Sources) Local() Source { return s.local } // List returns the local source followed by the saved ones, credentials // stripped, in name order. func (s *Sources) List() []Source { s.mu.RLock() defer s.mu.RUnlock() out := make([]Source, 0, len(s.items)+1) for _, src := range s.items { out = append(out, redactSource(src)) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return append([]Source{s.local}, out...) } // Get returns a source ready to connect to, with credentials filled back in. func (s *Sources) Get(id string) (Source, error) { if id == "" || id == LocalSourceID { return s.local, nil } s.mu.RLock() defer s.mu.RUnlock() src, ok := s.items[id] if !ok { return Source{}, ErrNotFound } if src.SSH != nil { cfg := *src.SSH if sec, ok := s.secrets[id]; ok { if cfg.Password == "" { cfg.Password = sec.Password } if cfg.PrivateKey == "" { cfg.PrivateKey = sec.PrivateKey } if cfg.Passphrase == "" { cfg.Passphrase = sec.Passphrase } } src.SSH = &cfg } return src, nil } // Selected returns the id of the current source, falling back to the local one. func (s *Sources) Selected() string { s.mu.RLock() defer s.mu.RUnlock() return s.selected } // Select records which source is in use. It does not connect: that is the // caller's job, so a selection is only stored once it has been shown to work. func (s *Sources) Select(id string) error { if id == "" { id = LocalSourceID } s.mu.Lock() defer s.mu.Unlock() if id != LocalSourceID { if _, ok := s.items[id]; !ok { return ErrNotFound } } if s.selected == id { return nil } s.selected = id return s.flush() } // Save inserts or updates a source and returns the stored, redacted form. // // As with connections, an update that omits credentials keeps the ones already // held, so a source can be edited without re-entering a password. func (s *Sources) Save(src Source) (Source, error) { if src.ID == LocalSourceID { return Source{}, errors.New("the local source cannot be edited") } switch src.Kind { case SourceDocker: src.DockerHost = strings.TrimSpace(src.DockerHost) if src.DockerHost == "" { return Source{}, errors.New("a docker address is required, e.g. tcp://10.0.0.5:2375") } if !strings.Contains(src.DockerHost, "://") { return Source{}, fmt.Errorf("%q is not a docker address; it needs a scheme, e.g. tcp://%s", src.DockerHost, src.DockerHost) } src.SSH = nil if src.Name == "" { src.Name = src.DockerHost } case SourceSSH: if src.SSH == nil || src.SSH.Host == "" { return Source{}, errors.New("host is required") } if src.SSH.User == "" { return Source{}, errors.New("user is required") } if src.SSH.Port == 0 { src.SSH.Port = 22 } src.DockerHost = "" if src.Name == "" { src.Name = src.SSH.Host } case SourceLocal: return Source{}, errors.New("there is only one local source") default: return Source{}, fmt.Errorf("unknown source kind %q", src.Kind) } s.mu.Lock() defer s.mu.Unlock() if src.ID == "" { src.ID = newID() } if src.SSH != nil { prev := s.items[src.ID] prevSecret := s.secrets[src.ID] var prevSSH sshx.Config if prev.SSH != nil { prevSSH = *prev.SSH } if src.SSH.Password == "" { src.SSH.Password = firstNonEmpty(prevSSH.Password, prevSecret.Password) } if src.SSH.PrivateKey == "" { src.SSH.PrivateKey = firstNonEmpty(prevSSH.PrivateKey, prevSecret.PrivateKey) } if src.SSH.Passphrase == "" { src.SSH.Passphrase = firstNonEmpty(prevSSH.Passphrase, prevSecret.Passphrase) } // The SSH id is only meaningful inside the source that owns it. src.SSH.ID = src.ID src.SSH.Name = src.Name if src.SSH.SaveSecrets { delete(s.secrets, src.ID) s.items[src.ID] = src } else { s.secrets[src.ID] = secret{ Password: src.SSH.Password, PrivateKey: src.SSH.PrivateKey, Passphrase: src.SSH.Passphrase, } s.items[src.ID] = redactSource(src) } } else { s.items[src.ID] = src } if err := s.flush(); err != nil { return Source{}, err } return redactSource(s.items[src.ID]), nil } // Delete removes a source, falling back to the local one when the source being // removed is the selected one. func (s *Sources) Delete(id string) error { if id == LocalSourceID { return errors.New("the local source cannot be deleted") } s.mu.Lock() defer s.mu.Unlock() if _, ok := s.items[id]; !ok { return ErrNotFound } delete(s.items, id) delete(s.secrets, id) if s.selected == id { s.selected = LocalSourceID } return s.flush() } // flush writes the file. The caller must hold the write lock. func (s *Sources) flush() error { f := sourcesFile{Selected: s.selected, Sources: make([]Source, 0, len(s.items))} for _, src := range s.items { f.Sources = append(f.Sources, src) } sort.Slice(f.Sources, func(i, j int) bool { return f.Sources[i].ID < f.Sources[j].ID }) b, err := json.MarshalIndent(f, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return fmt.Errorf("write sources: %w", err) } if err := os.Rename(tmp, s.path); err != nil { return fmt.Errorf("replace sources file: %w", err) } return nil } func redactSource(src Source) Source { if src.SSH != nil { cfg := redact(*src.SSH) src.SSH = &cfg } return src }