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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/arescom/dockmv/internal/sshx"
|
||||
)
|
||||
|
||||
func newTestSources(t *testing.T) (*Sources, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "sources.json")
|
||||
s, err := NewSources(path, Source{Name: "this host", DockerHost: "unix:///var/run/docker.sock"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSources: %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
func TestSourcesLocalIsAlwaysPresent(t *testing.T) {
|
||||
s, _ := newTestSources(t)
|
||||
|
||||
list := s.List()
|
||||
if len(list) != 1 || list[0].ID != LocalSourceID || list[0].Kind != SourceLocal {
|
||||
t.Fatalf("expected only the local source, got %+v", list)
|
||||
}
|
||||
if got := s.Selected(); got != LocalSourceID {
|
||||
t.Fatalf("selected = %q, want %q", got, LocalSourceID)
|
||||
}
|
||||
if _, err := s.Save(Source{ID: LocalSourceID, Kind: SourceDocker, DockerHost: "tcp://x:2375"}); err == nil {
|
||||
t.Fatal("editing the local source should be refused")
|
||||
}
|
||||
if err := s.Delete(LocalSourceID); err == nil {
|
||||
t.Fatal("deleting the local source should be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesValidation(t *testing.T) {
|
||||
s, _ := newTestSources(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
src Source
|
||||
want string
|
||||
}{
|
||||
{"no docker address", Source{Kind: SourceDocker}, "docker address is required"},
|
||||
{"address without scheme", Source{Kind: SourceDocker, DockerHost: "10.0.0.5:2375"}, "needs a scheme"},
|
||||
{"ssh without host", Source{Kind: SourceSSH, SSH: &sshx.Config{User: "root"}}, "host is required"},
|
||||
{"ssh without user", Source{Kind: SourceSSH, SSH: &sshx.Config{Host: "h"}}, "user is required"},
|
||||
{"unknown kind", Source{Kind: "carrier-pigeon"}, "unknown source kind"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := s.Save(tc.src); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want it to mention %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesSaveDefaults(t *testing.T) {
|
||||
s, _ := newTestSources(t)
|
||||
|
||||
saved, err := s.Save(Source{Kind: SourceSSH, SSH: &sshx.Config{Host: "10.0.0.9", User: "root"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if saved.ID == "" {
|
||||
t.Fatal("an id should have been generated")
|
||||
}
|
||||
if saved.Name != "10.0.0.9" {
|
||||
t.Fatalf("name = %q, want the host as a fallback", saved.Name)
|
||||
}
|
||||
if saved.SSH.Port != 22 {
|
||||
t.Fatalf("port = %d, want 22", saved.SSH.Port)
|
||||
}
|
||||
|
||||
// A docker source drops any ssh configuration, and the other way round.
|
||||
dock, err := s.Save(Source{Kind: SourceDocker, DockerHost: "tcp://10.0.0.5:2375", SSH: &sshx.Config{Host: "x", User: "y"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if dock.SSH != nil {
|
||||
t.Fatalf("ssh configuration should be dropped for a docker source: %+v", dock.SSH)
|
||||
}
|
||||
if dock.Name != "tcp://10.0.0.5:2375" {
|
||||
t.Fatalf("name = %q, want the address as a fallback", dock.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesSecretsStayOffDiskUnlessAsked(t *testing.T) {
|
||||
s, path := newTestSources(t)
|
||||
|
||||
kept, err := s.Save(Source{
|
||||
Kind: SourceSSH,
|
||||
SSH: &sshx.Config{Host: "h1", User: "root", Auth: sshx.AuthPassword, Password: "in-memory"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if kept.SSH.Password != "" {
|
||||
t.Fatal("the returned form must be redacted")
|
||||
}
|
||||
if lst := s.List(); lst[1].SSH.Password != "" {
|
||||
t.Fatal("List must not hand out credentials")
|
||||
}
|
||||
// Get is the dialling path, so it does see the password.
|
||||
got, err := s.Get(kept.ID)
|
||||
if err != nil || got.SSH.Password != "in-memory" {
|
||||
t.Fatalf("Get password = %q (err %v), want the in-memory secret", got.SSH.Password, err)
|
||||
}
|
||||
|
||||
remembered, err := s.Save(Source{
|
||||
Kind: SourceSSH,
|
||||
SSH: &sshx.Config{Host: "h2", User: "root", Auth: sshx.AuthPassword, Password: "on-disk", SaveSecrets: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file: %v", err)
|
||||
}
|
||||
if strings.Contains(string(b), "in-memory") {
|
||||
t.Fatal("a secret the operator did not want persisted reached the disk")
|
||||
}
|
||||
if !strings.Contains(string(b), "on-disk") {
|
||||
t.Fatal("a remembered secret should have been written")
|
||||
}
|
||||
|
||||
// An update that omits the password keeps the one already held.
|
||||
again, err := s.Save(Source{ID: remembered.ID, Kind: SourceSSH, SSH: &sshx.Config{Host: "h2", User: "admin", SaveSecrets: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
reloaded, err := s.Get(again.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if reloaded.SSH.Password != "on-disk" {
|
||||
t.Fatalf("password = %q, want it carried forward", reloaded.SSH.Password)
|
||||
}
|
||||
if reloaded.SSH.User != "admin" {
|
||||
t.Fatalf("user = %q, want the update to apply", reloaded.SSH.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesSelectionSurvivesRestart(t *testing.T) {
|
||||
s, path := newTestSources(t)
|
||||
|
||||
saved, err := s.Save(Source{Name: "prod", Kind: SourceSSH, SSH: &sshx.Config{Host: "10.0.0.9", User: "root", SaveSecrets: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if err := s.Select("nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Select of an unknown id = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := s.Select(saved.ID); err != nil {
|
||||
t.Fatalf("Select: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := NewSources(path, Source{Name: "this host"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSources: %v", err)
|
||||
}
|
||||
if got := reopened.Selected(); got != saved.ID {
|
||||
t.Fatalf("selected after restart = %q, want %q", got, saved.ID)
|
||||
}
|
||||
if len(reopened.List()) != 2 {
|
||||
t.Fatalf("sources after restart = %+v", reopened.List())
|
||||
}
|
||||
|
||||
// Deleting the selected source falls back to the local one.
|
||||
if err := reopened.Delete(saved.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if got := reopened.Selected(); got != LocalSourceID {
|
||||
t.Fatalf("selected after delete = %q, want %q", got, LocalSourceID)
|
||||
}
|
||||
if _, err := reopened.Get(saved.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Get after delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesUnknownSelectionIgnoredOnLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sources.json")
|
||||
body := `{"selected":"gone","sources":[{"id":"gone-too","name":"x","kind":"docker","dockerHost":"tcp://h:2375"}]}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
s, err := NewSources(path, Source{Name: "this host"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSources: %v", err)
|
||||
}
|
||||
if got := s.Selected(); got != LocalSourceID {
|
||||
t.Fatalf("selected = %q, want the local fallback", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user