Add SSH source support with dialstdio and UI components

- Implement SSH dial via stdio for remote connections
- Add sources API and storage layer for managing connection sources
- Add SourcePanel and SshFields web components for SSH configuration
- Update app structure to support source-based connections
- Update handlers and server for new sources endpoint

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 10:19:47 +02:00
co-authored by Claude Haiku 4.5
parent 05db8bfeb9
commit 9b354636bb
19 changed files with 1857 additions and 174 deletions
+315
View File
@@ -0,0 +1,315 @@
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
}