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:
+76
-14
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,9 @@ package dkr
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/system"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -33,6 +36,43 @@ func New(host string) (*Client, error) {
|
||||
return &Client{api: api, Endpoint: api.DaemonHost()}, nil
|
||||
}
|
||||
|
||||
// Dialer opens one connection to a daemon's API socket.
|
||||
type Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// NewTunnel connects to a daemon that is only reachable through dial, such as a
|
||||
// remote daemon behind an SSH connection. The HTTP host is a placeholder: every
|
||||
// connection comes from dial, so the address is never resolved.
|
||||
//
|
||||
// endpoint is what the UI displays, e.g. ssh://root@10.0.0.5.
|
||||
func NewTunnel(endpoint string, dial Dialer) (*Client, error) {
|
||||
// The transport is ours so that WithHost cannot leave a TCP dialer or the
|
||||
// environment's HTTP proxy in place; either would send API calls somewhere
|
||||
// other than through the tunnel.
|
||||
tr := &http.Transport{
|
||||
DisableCompression: true,
|
||||
// Every connection through the tunnel costs one SSH channel, and sshd
|
||||
// allows ten per connection by default (MaxSessions). Capping the pool
|
||||
// keeps a parallel migration from exhausting them; extra calls wait.
|
||||
MaxConnsPerHost: 8,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 5 * time.Minute,
|
||||
}
|
||||
api, err := client.NewClientWithOpts(
|
||||
client.WithHTTPClient(&http.Client{Transport: tr}),
|
||||
client.WithHost("http://docker.tunnel.invalid"),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create docker client: %w", err)
|
||||
}
|
||||
tr.Proxy = nil
|
||||
tr.DialContext = dial
|
||||
if endpoint == "" {
|
||||
endpoint = "tunnel"
|
||||
}
|
||||
return &Client{api: api, Endpoint: endpoint}, nil
|
||||
}
|
||||
|
||||
// API exposes the underlying SDK client for callers that need an operation
|
||||
// this package does not wrap.
|
||||
func (c *Client) API() *client.Client { return c.api }
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package sshx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// DialAPI opens one connection to the remote daemon's API by running
|
||||
// `docker system dial-stdio` over SSH and treating that session's stdin and
|
||||
// stdout as a socket. It is the same mechanism `docker -H ssh://…` uses, so the
|
||||
// remote host still needs nothing but sshd and the docker CLI.
|
||||
//
|
||||
// The returned connection is what dkr.NewTunnel dials through: from there on the
|
||||
// whole Docker Engine API — inventory, archive streams, image save — is
|
||||
// available on a remote source host exactly as it is on a local one.
|
||||
func (r *RemoteDocker) DialAPI(ctx context.Context) (net.Conn, error) {
|
||||
sess, err := r.c.conn.NewSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open ssh session: %w", err)
|
||||
}
|
||||
stdin, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
sess.Close()
|
||||
return nil, fmt.Errorf("attach to remote stdin: %w", err)
|
||||
}
|
||||
stdout, err := sess.StdoutPipe()
|
||||
if err != nil {
|
||||
sess.Close()
|
||||
return nil, fmt.Errorf("attach to remote stdout: %w", err)
|
||||
}
|
||||
errBuf := &syncBuffer{}
|
||||
sess.Stderr = errBuf
|
||||
|
||||
cmd := r.Cmd("system", "dial-stdio")
|
||||
if err := sess.Start(cmd); err != nil {
|
||||
sess.Close()
|
||||
return nil, fmt.Errorf("start %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
cfg := r.c.Config()
|
||||
// The connection deliberately outlives ctx: the HTTP transport keeps it in
|
||||
// its idle pool between API calls, and closes it itself when a request is
|
||||
// cancelled or the client is closed.
|
||||
return &apiConn{
|
||||
sess: sess, stdin: stdin, stdout: stdout, stderr: errBuf,
|
||||
remote: apiAddr(fmt.Sprintf("%s@%s", cfg.User, cfg.addr())),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ProbeCLI checks that the remote docker CLI is usable before the API is
|
||||
// tunnelled through it, so a missing binary or a permission problem is reported
|
||||
// as itself rather than as a broken socket. It returns the daemon version.
|
||||
func (r *RemoteDocker) ProbeCLI(ctx context.Context) (string, error) {
|
||||
out, ok, err := r.Try(ctx, "version", "--format", "{{.Server.Version}}")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok {
|
||||
if v := strings.TrimSpace(out); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
res, err := r.c.Run(ctx, r.Cmd("version"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
msg := strings.TrimSpace(res.Stderr)
|
||||
switch {
|
||||
case strings.Contains(msg, "permission denied"):
|
||||
return "", errors.New("the login user cannot talk to the docker daemon; " +
|
||||
"add it to the docker group, or enable sudo -n for this source")
|
||||
case msg != "":
|
||||
return "", errors.New("docker is not usable on that host: " + firstLine(msg))
|
||||
default:
|
||||
return "", errors.New("docker is not installed or not on PATH on that host")
|
||||
}
|
||||
}
|
||||
|
||||
// apiConn adapts an SSH session to net.Conn.
|
||||
type apiConn struct {
|
||||
sess *ssh.Session
|
||||
stdin io.WriteCloser
|
||||
stdout io.Reader
|
||||
stderr *syncBuffer
|
||||
remote apiAddr
|
||||
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *apiConn) Read(p []byte) (int, error) {
|
||||
n, err := c.stdout.Read(p)
|
||||
if err != nil {
|
||||
return n, c.wrap(err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *apiConn) Write(p []byte) (int, error) {
|
||||
n, err := c.stdin.Write(p)
|
||||
if err != nil {
|
||||
return n, c.wrap(err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// wrap replaces the bare EOF a failed remote command produces with whatever it
|
||||
// printed on stderr, which is the only place the reason appears.
|
||||
func (c *apiConn) wrap(err error) error {
|
||||
if msg := strings.TrimSpace(c.stderr.String()); msg != "" {
|
||||
return fmt.Errorf("docker system dial-stdio on the remote host failed: %s", firstLine(msg))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *apiConn) Close() error {
|
||||
c.once.Do(func() {
|
||||
// Closing stdin lets the remote docker exit cleanly; the session is torn
|
||||
// down straight after either way.
|
||||
_ = c.stdin.Close()
|
||||
c.err = c.sess.Close()
|
||||
if errors.Is(c.err, io.EOF) {
|
||||
c.err = nil
|
||||
}
|
||||
})
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *apiConn) LocalAddr() net.Addr { return apiAddr("dockmv") }
|
||||
func (c *apiConn) RemoteAddr() net.Addr { return c.remote }
|
||||
|
||||
// The deadline calls are no-ops: an SSH channel has no deadline of its own, and
|
||||
// the Docker client relies on context cancellation rather than on these. This
|
||||
// mirrors what the docker CLI's own ssh:// transport does.
|
||||
func (c *apiConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *apiConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *apiConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
type apiAddr string
|
||||
|
||||
func (a apiAddr) Network() string { return "ssh" }
|
||||
func (a apiAddr) String() string { return string(a) }
|
||||
|
||||
// syncBuffer collects remote stderr, which the ssh session writes from its own
|
||||
// goroutine while the connection is being read.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sshx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDialAPICommand pins the command the tunnel runs on the source host: it is
|
||||
// the same one `docker -H ssh://…` uses, and the sudo / custom binary settings
|
||||
// have to reach it.
|
||||
func TestDialAPICommand(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rd *RemoteDocker
|
||||
want string
|
||||
}{
|
||||
{"plain", &RemoteDocker{binary: "docker"}, "docker system dial-stdio"},
|
||||
{"sudo", &RemoteDocker{binary: "docker", sudo: true}, "sudo -n docker system dial-stdio"},
|
||||
{"podman", &RemoteDocker{binary: "podman"}, "podman system dial-stdio"},
|
||||
{"path with a space", &RemoteDocker{binary: "/opt/my docker/bin/docker"}, "'/opt/my docker/bin/docker' system dial-stdio"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.rd.Cmd("system", "dial-stdio"); got != tc.want {
|
||||
t.Fatalf("command = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIConnSurfacesRemoteStderr covers the failure that would otherwise reach
|
||||
// the Docker client as a bare EOF: the remote docker printing a reason and
|
||||
// exiting.
|
||||
func TestAPIConnSurfacesRemoteStderr(t *testing.T) {
|
||||
errBuf := &syncBuffer{}
|
||||
errBuf.Write([]byte("docker: 'system dial-stdio' is not a docker command\n"))
|
||||
c := &apiConn{
|
||||
stdout: strings.NewReader(""),
|
||||
stdin: nopWriteCloser{io.Discard},
|
||||
stderr: errBuf,
|
||||
}
|
||||
_, err := c.Read(make([]byte, 8))
|
||||
if err == nil {
|
||||
t.Fatal("a closed stream with remote stderr should be an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is not a docker command") {
|
||||
t.Fatalf("error = %v, want the remote stderr in it", err)
|
||||
}
|
||||
// Without stderr the plain EOF must survive, or the HTTP transport cannot
|
||||
// tell a finished response from a broken one.
|
||||
quiet := &apiConn{stdout: strings.NewReader(""), stdin: nopWriteCloser{io.Discard}, stderr: &syncBuffer{}}
|
||||
if _, err := quiet.Read(make([]byte, 8)); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("error = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIConnDeadlinesAreNoops(t *testing.T) {
|
||||
var c net.Conn = &apiConn{stdout: strings.NewReader(""), stdin: nopWriteCloser{io.Discard}, stderr: &syncBuffer{}}
|
||||
now := time.Now()
|
||||
if err := c.SetDeadline(now); err != nil {
|
||||
t.Fatalf("SetDeadline: %v", err)
|
||||
}
|
||||
if err := c.SetReadDeadline(now); err != nil {
|
||||
t.Fatalf("SetReadDeadline: %v", err)
|
||||
}
|
||||
if err := c.SetWriteDeadline(now); err != nil {
|
||||
t.Fatalf("SetWriteDeadline: %v", err)
|
||||
}
|
||||
if c.RemoteAddr().Network() != "ssh" {
|
||||
t.Fatalf("network = %q, want ssh", c.RemoteAddr().Network())
|
||||
}
|
||||
}
|
||||
|
||||
type nopWriteCloser struct{ io.Writer }
|
||||
|
||||
func (nopWriteCloser) Close() error { return nil }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>DockMV</title>
|
||||
<script type="module" crossorigin src="/assets/index-2w4y0Lpg.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-qcSVszEj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKzWD9Xt.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user