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
+76 -14
View File
@@ -21,23 +21,39 @@ import (
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
body := map[string]any{
"ok": true,
"dockerHost": s.docker.Endpoint,
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
"authRequired": s.cfg.Token != "",
}
if v, err := s.docker.Ping(ctx); err != nil {
// Health is also what connects to the selected source on a fresh start, so
// the UI learns straight away when the remembered source is unreachable.
conn, release, err := s.source(ctx)
if err != nil {
selected, _ := s.sources.Get(s.sources.Selected())
body["ok"] = false
body["dockerError"] = err.Error()
} else {
body["dockerVersion"] = v
endpoint := sourceEndpoint(selected)
body["dockerHost"] = endpoint
body["source"] = sourceStatus{
ID: selected.ID, Name: selected.Name, Kind: selected.Kind,
Endpoint: endpoint, Error: err.Error(),
}
writeJSON(w, http.StatusOK, body)
return
}
defer release()
st := conn.status()
body["dockerHost"] = st.Endpoint
body["dockerVersion"] = st.DockerVersion
body["source"] = st
writeJSON(w, http.StatusOK, body)
}
@@ -47,7 +63,14 @@ func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
conn, release, err := s.source(ctx)
if err != nil {
s.writeDialError(w, err)
return
}
defer release()
inv, err := conn.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
@@ -69,7 +92,14 @@ func (s *Server) handleSourceSizes(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
sizes, err := s.docker.VolumeSizes(ctx)
conn, release, err := s.source(ctx)
if err != nil {
s.writeDialError(w, err)
return
}
defer release()
sizes, err := conn.docker.VolumeSizes(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
@@ -209,7 +239,14 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
conn, release, err := s.source(ctx)
if err != nil {
s.writeDialError(w, err)
return
}
defer release()
inv, err := conn.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
@@ -299,12 +336,23 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
return
}
// The source is held for the whole job: picking another source in the UI
// while this runs must not close the socket it is reading from.
srcCtx, srcCancel := context.WithTimeout(r.Context(), 60*time.Second)
src, release, err := s.source(srcCtx)
srcCancel()
if err != nil {
s.writeDialError(w, err)
return
}
// The inventory is re-read now so the plan is applied to current state
// rather than to whatever the browser last loaded.
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
inv, err := src.docker.Inventory(invCtx)
cancel()
if err != nil {
release()
writeError(w, http.StatusBadGateway, "%v", err)
return
}
@@ -315,13 +363,14 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
dialCancel()
if err != nil {
release()
s.writeDialError(w, err)
return
}
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
title := fmt.Sprintf("%d container(s) from %s to %s", countIncluded(req.Plan), src.src.Name, cfg.Name)
runner := &migrate.SSHRunner{
Src: s.docker,
Src: src.docker,
Dst: sshx.NewRemoteDocker(client),
Containers: inv.Containers,
Volumes: inv.Volumes,
@@ -331,8 +380,10 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
defer release()
defer client.Close()
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
j.Logf(job.LevelInfo, "", "migrating from %s to %s@%s over ssh",
src.docker.Endpoint, cfg.User, cfg.Host)
return runner.Run(ctx, j)
})
@@ -362,16 +413,25 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
return
}
srcCtx, srcCancel := context.WithTimeout(r.Context(), 60*time.Second)
src, release, err := s.source(srcCtx)
srcCancel()
if err != nil {
s.writeDialError(w, err)
return
}
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
inv, err := src.docker.Inventory(invCtx)
cancel()
if err != nil {
release()
writeError(w, http.StatusBadGateway, "%v", err)
return
}
packager := &migrate.Packager{
Src: s.docker,
Src: src.docker,
Containers: inv.Containers,
Volumes: inv.Volumes,
Networks: inv.Networks,
@@ -384,6 +444,8 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
defer release()
j.Logf(job.LevelInfo, "", "reading from %s", src.docker.Endpoint)
res, err := packager.Run(ctx, j)
if err != nil {
return err
+46 -17
View File
@@ -11,9 +11,9 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/arescom/dockmv/internal/dkr"
"github.com/arescom/dockmv/internal/job"
"github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/internal/store"
@@ -29,23 +29,31 @@ type Config struct {
DataDir string
// PackageDir is where migration packages are written.
PackageDir string
// DockerHost overrides the source daemon address.
// DockerHost overrides the local source daemon address.
DockerHost string
// DockerHostSet reports that DockerHost was given explicitly on the command
// line. It then wins over the source remembered from the last run.
DockerHostSet bool
// UI is the embedded web app; nil disables the UI.
UI fs.FS
// Logger receives request and error logs.
Logger *slog.Logger
}
// Server ties the Docker client, connection store and job manager to HTTP.
// Server ties the source daemon, connection store and job manager to HTTP.
type Server struct {
cfg Config
log *slog.Logger
docker *dkr.Client
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
mux *http.ServeMux
cfg Config
log *slog.Logger
sources *store.Sources
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
mux *http.ServeMux
// srcMu guards cur, the connection to the selected source. It is opened on
// first use and replaced when the operator picks another source.
srcMu sync.Mutex
cur *sourceConn
}
// New builds the server and everything it owns.
@@ -60,10 +68,6 @@ func New(cfg Config) (*Server, error) {
return nil, fmt.Errorf("create package directory: %w", err)
}
docker, err := dkr.New(cfg.DockerHost)
if err != nil {
return nil, err
}
conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
if err != nil {
return nil, err
@@ -72,17 +76,35 @@ func New(cfg Config) (*Server, error) {
if err != nil {
return nil, err
}
local := store.Source{Name: "this host", DockerHost: cfg.DockerHost}
if local.DockerHost == "" {
local.DockerHost = os.Getenv("DOCKER_HOST")
}
sources, err := store.NewSources(filepath.Join(cfg.DataDir, "sources.json"), local)
if err != nil {
return nil, err
}
// An explicit --docker-host is an instruction for this run, so it overrides
// the source remembered from the last one.
if cfg.DockerHostSet {
if err := sources.Select(store.LocalSourceID); err != nil {
return nil, err
}
}
s := &Server{
cfg: cfg, log: cfg.Logger, docker: docker,
cfg: cfg, log: cfg.Logger, sources: sources,
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
}
s.routes()
return s, nil
}
// Close releases the Docker connection.
func (s *Server) Close() error { return s.docker.Close() }
// Close releases the connection to the current source.
func (s *Server) Close() error {
s.invalidateSource("")
return nil
}
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
@@ -96,6 +118,13 @@ func (s *Server) routes() {
m.HandleFunc("GET /api/source", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
m.HandleFunc("GET /api/sources", s.handleListSources)
m.HandleFunc("POST /api/sources", s.handleSaveSource)
m.HandleFunc("DELETE /api/sources/{id}", s.handleDeleteSource)
m.HandleFunc("POST /api/sources/{id}/select", s.handleSelectSource)
m.HandleFunc("POST /api/sources/{id}/probe", s.handleSourceProbe)
m.HandleFunc("POST /api/sources/{id}/trust", s.handleSourceTrust)
m.HandleFunc("GET /api/connections", s.handleListConnections)
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
+328
View File
@@ -0,0 +1,328 @@
package api
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"time"
"github.com/arescom/dockmv/internal/dkr"
"github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/internal/store"
)
// sourceConn is a live connection to one source daemon.
//
// It is reference counted rather than closed eagerly: a migration holds its
// source for as long as it runs, so switching source in the UI half way through
// a transfer must not pull the socket out from under it. The connection is
// closed once it is both replaced and unused.
type sourceConn struct {
src store.Source
docker *dkr.Client
ssh *sshx.Client
version string
refs int
stale bool
}
func (c *sourceConn) close() {
if c.docker != nil {
_ = c.docker.Close()
}
if c.ssh != nil {
_ = c.ssh.Close()
}
}
// sourceStatus is what the UI shows about the source it is looking at.
type sourceStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Kind store.SourceKind `json:"kind"`
Endpoint string `json:"endpoint"`
DockerVersion string `json:"dockerVersion,omitempty"`
Connected bool `json:"connected"`
Error string `json:"error,omitempty"`
}
func (c *sourceConn) status() sourceStatus {
return sourceStatus{
ID: c.src.ID, Name: c.src.Name, Kind: c.src.Kind,
Endpoint: c.docker.Endpoint, DockerVersion: c.version, Connected: true,
}
}
// source returns the current source, connecting to it on first use. The
// returned release function must be called when the caller is done with it —
// for a job, when the job finishes.
func (s *Server) source(ctx context.Context) (*sourceConn, func(), error) {
s.srcMu.Lock()
if cur := s.cur; cur != nil {
cur.refs++
s.srcMu.Unlock()
return cur, func() { s.releaseSource(cur) }, nil
}
id := s.sources.Selected()
s.srcMu.Unlock()
conn, err := s.dialSource(ctx, id)
if err != nil {
return nil, nil, err
}
s.srcMu.Lock()
defer s.srcMu.Unlock()
// Another request may have connected while this one was dialling; one
// connection is enough, so the loser is dropped.
if s.cur != nil {
conn.close()
conn = s.cur
} else {
s.cur = conn
}
conn.refs++
return conn, func() { s.releaseSource(conn) }, nil
}
func (s *Server) releaseSource(c *sourceConn) {
s.srcMu.Lock()
defer s.srcMu.Unlock()
c.refs--
if c.refs <= 0 && c.stale {
c.close()
}
}
// selectSource connects to a source and, once that worked, makes it the current
// one and records the choice for the next run.
func (s *Server) selectSource(ctx context.Context, id string) (sourceStatus, error) {
conn, err := s.dialSource(ctx, id)
if err != nil {
return sourceStatus{}, err
}
st := conn.status()
s.srcMu.Lock()
old := s.cur
s.cur = conn
if old != nil {
old.stale = true
if old.refs <= 0 {
old.close()
}
}
s.srcMu.Unlock()
if err := s.sources.Select(conn.src.ID); err != nil {
return st, fmt.Errorf("remember the selected source: %w", err)
}
s.log.Info("source selected", "id", conn.src.ID, "endpoint", conn.docker.Endpoint)
return st, nil
}
// invalidateSource drops the cached connection when the source behind it has
// been edited or removed. An empty id invalidates whatever is current.
func (s *Server) invalidateSource(id string) {
s.srcMu.Lock()
defer s.srcMu.Unlock()
if s.cur == nil {
return
}
if id != "" && s.cur.src.ID != id {
return
}
s.cur.stale = true
if s.cur.refs <= 0 {
s.cur.close()
}
s.cur = nil
}
// dialSource opens a connection to one source and verifies the daemon answers.
func (s *Server) dialSource(ctx context.Context, id string) (*sourceConn, error) {
src, err := s.sources.Get(id)
if err != nil {
return nil, err
}
conn := &sourceConn{src: src}
switch src.Kind {
case store.SourceLocal, store.SourceDocker:
c, err := dkr.New(src.DockerHost)
if err != nil {
return nil, err
}
conn.docker = c
case store.SourceSSH:
if src.SSH == nil {
return nil, errors.New("source has no ssh configuration")
}
client, err := sshx.Dial(ctx, *src.SSH, s.hosts)
if err != nil {
return nil, err
}
rd := sshx.NewRemoteDocker(client)
// The CLI is checked first: a missing binary or a user outside the
// docker group is a readable error here, and an unexplained broken
// socket if it is left to the tunnel.
if _, err := rd.ProbeCLI(ctx); err != nil {
client.Close()
return nil, err
}
c, err := dkr.NewTunnel(sourceEndpoint(src), func(ctx context.Context, _, _ string) (net.Conn, error) {
return rd.DialAPI(ctx)
})
if err != nil {
client.Close()
return nil, err
}
conn.ssh, conn.docker = client, c
default:
return nil, fmt.Errorf("unknown source kind %q", src.Kind)
}
pingCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
version, err := conn.docker.Ping(pingCtx)
if err != nil {
conn.close()
return nil, fmt.Errorf("connect to docker at %s: %w", conn.docker.Endpoint, err)
}
conn.version = version
return conn, nil
}
// sourceEndpoint describes where a source lives, before and after it is
// connected to.
func sourceEndpoint(src store.Source) string {
if src.Kind == store.SourceSSH && src.SSH != nil {
port := src.SSH.Port
if port == 0 {
port = 22
}
return fmt.Sprintf("ssh://%s@%s", src.SSH.User, net.JoinHostPort(src.SSH.Host, strconv.Itoa(port)))
}
return src.DockerHost
}
// handleListSources lists the sources without connecting to any of them; the
// state of the current one comes from /api/health.
func (s *Server) handleListSources(w http.ResponseWriter, r *http.Request) {
s.srcMu.Lock()
var current *sourceStatus
if s.cur != nil {
st := s.cur.status()
current = &st
}
s.srcMu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{
"sources": s.sources.List(),
"selected": s.sources.Selected(),
"current": current,
})
}
func (s *Server) handleSaveSource(w http.ResponseWriter, r *http.Request) {
var src store.Source
if err := decode(r, &src); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
saved, err := s.sources.Save(src)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
// Editing the source in use means the live connection describes the old
// settings; drop it so the next call reconnects.
s.invalidateSource(saved.ID)
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleDeleteSource(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.sources.Delete(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "%v", err)
return
}
writeError(w, http.StatusBadRequest, "%v", err)
return
}
s.invalidateSource(id)
w.WriteHeader(http.StatusNoContent)
}
// handleSelectSource switches the source the whole UI works against.
func (s *Server) handleSelectSource(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
st, err := s.selectSource(ctx, r.PathValue("id"))
if err != nil {
s.writeDialError(w, err)
return
}
writeJSON(w, http.StatusOK, st)
}
// handleSourceProbe reads the SSH host key of a source host, so its fingerprint
// can be approved the same way a target's is.
func (s *Server) handleSourceProbe(w http.ResponseWriter, r *http.Request) {
cfg, err := s.sourceSSH(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
info, err := sshx.Probe(ctx, cfg, s.hosts)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, info)
}
func (s *Server) handleSourceTrust(w http.ResponseWriter, r *http.Request) {
cfg, err := s.sourceSSH(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
var body struct {
Fingerprint string `json:"fingerprint"`
}
if err := decode(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := sshx.TrustFromProbe(ctx, cfg, s.hosts, body.Fingerprint); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"trusted": true})
}
// sourceSSH returns the SSH configuration of a source, refusing the ones that
// are not reached over SSH.
func (s *Server) sourceSSH(id string) (sshx.Config, error) {
src, err := s.sources.Get(id)
if err != nil {
return sshx.Config{}, err
}
if src.Kind != store.SourceSSH || src.SSH == nil {
return sshx.Config{}, fmt.Errorf("source %s is not reached over ssh", src.Name)
}
return *src.SSH, nil
}