- 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>
329 lines
8.6 KiB
Go
329 lines
8.6 KiB
Go
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
|
|
}
|