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
+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
}