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
+51 -13
View File
@@ -15,11 +15,16 @@ Two ways to move things:
The target needs **nothing installed**: no agent, no Python, no Go — just `sshd`, `docker`, `bash` and `gzip`. The target needs **nothing installed**: no agent, no Python, no Go — just `sshd`, `docker`, `bash` and `gzip`.
The **source** is picked in the UI: the daemon DockMV runs next to, another daemon by address, or a
remote host over SSH — which needs nothing installed either. One DockMV can therefore move containers
between any two of your hosts.
--- ---
## Install ## Install
Run DockMV on the **source** host (the one holding the containers to move). Run DockMV on the **source** host (the one holding the containers to move), or anywhere that can
reach it — see [sources](#sources).
### Docker Compose — recommended ### Docker Compose — recommended
@@ -66,7 +71,7 @@ make build # rebuilds the UI, then the binary
## Dependencies ## Dependencies
### Source host — where DockMV runs ### Source host — where the containers are
| Requirement | Notes | | Requirement | Notes |
| --- | --- | | --- | --- |
@@ -75,6 +80,9 @@ make build # rebuilds the UI, then the binary
Nothing else. The binary is static: no libc, no runtime, no Python. Nothing else. The binary is static: no libc, no runtime, no Python.
A **remote** source needs the same as a target — `sshd` and a `docker` CLI of 18.09 or newer, since
the Engine API is tunnelled through `docker system dial-stdio`. Nothing is installed there either.
### Target host — where containers land ### Target host — where containers land
| Requirement | Why | | Requirement | Why |
@@ -104,17 +112,37 @@ on the Go side; React 19, Vite 7 and TypeScript 5.9 on the UI side. That is the
## Using it ## Using it
1. **Containers tab** — everything on the source host, grouped by compose project. Tick what to move. 1. **Source host** — top of the right panel. Defaults to the daemon DockMV runs next to; pick another
2. **Expand a row** (`▸`) for per-container details: target name, image pulled or transferred, one to read a different host. See [sources](#sources).
2. **Containers tab** — everything on the source host, grouped by compose project. Tick what to move.
3. **Expand a row** (`▸`) for per-container details: target name, image pulled or transferred,
networks and ports, and — per mount — **copy the data**, **create it empty**, or **do not mount it**. networks and ports, and — per mount — **copy the data**, **create it empty**, or **do not mount it**.
Bind mounts can be relocated; named volumes renamed. Bind mounts can be relocated; named volumes renamed.
3. **Apply to selected** does the same thing to every selected container at once. 4. **Apply to selected** does the same thing to every selected container at once.
4. **Right panel** — add the target host, *connect*, then **migrate over SSH** or **build a package**. 5. **Right panel** — add the target host, *connect*, then **migrate over SSH** or **build a package**.
5. **Jobs tab** — live progress per container and per mount, with the full command log. 6. **Jobs tab** — live progress per container and per mount, with the full command log.
Start with **dry run** ticked: it runs every check and prints every command without touching the target. Start with **dry run** ticked: it runs every check and prints every command without touching the target.
**Preview the commands** shows the exact `docker` invocations that will run. Nothing is hidden. **Preview the commands** shows the exact `docker` invocations that will run. Nothing is hidden.
### Sources
Three kinds, all interchangeable once selected — the container list, the preview, the migration and
the package build all read from whichever source is active:
| Kind | How it is reached | Notes |
| --- | --- | --- |
| **this host** | the socket in `DOCKER_HOST`, or `--docker-host` | always present; cannot be edited or removed |
| **docker address** | `tcp://host:2375`, or another `unix://` socket | TLS uses the certificates from `DOCKER_CERT_PATH` in DockMV's own environment. A plain `tcp://` daemon is unauthenticated — anyone who reaches that port is root on that host |
| **ssh** | the remote host's own docker CLI, through `docker system dial-stdio` | host keys are verified and credentials handled exactly like a target's |
The selected source is remembered in `<data-dir>/sources.json` and reselected on the next start; an
explicit `--docker-host` on the command line overrides it for that run. Saved sources whose
credentials you chose not to remember ask for them again after a restart.
With a remote source the data relays through DockMV — source → this host → target — so it crosses the
network twice. Running DockMV on the source host keeps it to one hop.
<details> <details>
<summary><b>How the data is actually moved</b></summary> <summary><b>How the data is actually moved</b></summary>
@@ -163,10 +191,11 @@ The tool can stop containers and read every volume on the host, so it is treated
admin tool: admin tool:
- Binds to **`127.0.0.1` by default**. Binding elsewhere auto-generates an access token and prints it. - Binds to **`127.0.0.1` by default**. Binding elsewhere auto-generates an access token and prints it.
- **SSH host keys are verified** like OpenSSH. An unknown key is refused until you approve the - **SSH host keys are verified** like OpenSSH, for sources as well as targets. An unknown key is
fingerprint in the UI; a *changed* key is refused outright. Trusted keys go to `<data-dir>/known_hosts`. refused until you approve the fingerprint in the UI; a *changed* key is refused outright. Trusted
keys go to `<data-dir>/known_hosts`.
- **Credentials are not persisted unless you ask.** *Remember* writes them to - **Credentials are not persisted unless you ask.** *Remember* writes them to
`<data-dir>/connections.json`, mode `0600`. `<data-dir>/connections.json` for targets and `<data-dir>/sources.json` for sources, mode `0600`.
- **Nothing on the target is overwritten by default.** An existing container name fails the item; - **Nothing on the target is overwritten by default.** An existing container name fails the item;
you pick *skip*, *rename* or *replace*. An existing volume is reused and merged into, never you pick *skip*, *rename* or *replace*. An existing volume is reused and merged into, never
silently deleted, unless you pick *replace*. silently deleted, unless you pick *replace*.
@@ -227,9 +256,10 @@ dockmv version
``` ```
--addr string address to listen on (default "127.0.0.1:8080") --addr string address to listen on (default "127.0.0.1:8080")
--token string require this token on every request; "auto" generates one --token string require this token on every request; "auto" generates one
--data-dir string connections and trusted host keys (default: OS config dir) --data-dir string sources, connections and trusted host keys (default: OS config dir)
--package-dir string where migration packages are written (default <data-dir>/packages) --package-dir string where migration packages are written (default <data-dir>/packages)
--docker-host string source docker daemon (default: the DOCKER_HOST environment) --docker-host string local source docker daemon (default: the DOCKER_HOST environment);
given explicitly, it overrides the remembered source
-v verbose logging -v verbose logging
``` ```
@@ -245,6 +275,12 @@ Everything the UI does is available over HTTP. Pass the token as `X-Auth-Token`
GET /api/health GET /api/health
GET /api/source inventory + default selections GET /api/source inventory + default selections
GET /api/source/sizes volume sizes (slow) GET /api/source/sizes volume sizes (slow)
GET /api/sources known sources + which one is selected
POST /api/sources
DELETE /api/sources/{id}
POST /api/sources/{id}/select switch the source everything reads from
POST /api/sources/{id}/probe read the SSH host key fingerprint
POST /api/sources/{id}/trust approve that fingerprint
GET /api/connections GET /api/connections
POST /api/connections POST /api/connections
DELETE /api/connections/{id} DELETE /api/connections/{id}
@@ -270,7 +306,9 @@ GET /api/packages, /api/packages/{name}/download
web/ React + TypeScript UI (vite) web/ React + TypeScript UI (vite)
internal/spec/ the transport model: a container, and how to render it back into docker flags internal/spec/ the transport model: a container, and how to render it back into docker flags
internal/dkr/ source Docker daemon: inventory, archive streams, image save internal/dkr/ source Docker daemon: inventory, archive streams, image save
internal/sshx/ SSH transport, host key trust, driving the target's docker CLI internal/sshx/ SSH transport, host key trust, driving the target's docker CLI, and
tunnelling a remote source's API through `docker system dial-stdio`
internal/store/ saved sources and target connections, and which source is selected
internal/migrate/ the two engines: SSH streaming, and package + installer generation internal/migrate/ the two engines: SSH streaming, and package + installer generation
internal/job/ progress tracking for long-running work internal/job/ progress tracking for long-running work
internal/api/ HTTP handlers and SSE internal/api/ HTTP handlers and SSE
+76 -14
View File
@@ -21,24 +21,40 @@ import (
) )
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { 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() defer cancel()
body := map[string]any{ body := map[string]any{
"ok": true, "ok": true,
"dockerHost": s.docker.Endpoint,
"packageDir": s.cfg.PackageDir, "packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir, "dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(), "knownHosts": s.hosts.Path(),
"authRequired": s.cfg.Token != "", "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["ok"] = false
body["dockerError"] = err.Error() body["dockerError"] = err.Error()
} else { endpoint := sourceEndpoint(selected)
body["dockerVersion"] = v 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) 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)
} }
// handleSource returns the full inventory of the source daemon, plus the // handleSource returns the full inventory of the source daemon, plus the
@@ -47,7 +63,14 @@ func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel() 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 { if err != nil {
writeError(w, http.StatusBadGateway, "%v", err) writeError(w, http.StatusBadGateway, "%v", err)
return return
@@ -69,7 +92,14 @@ func (s *Server) handleSourceSizes(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel() 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 { if err != nil {
writeError(w, http.StatusBadGateway, "%v", err) writeError(w, http.StatusBadGateway, "%v", err)
return return
@@ -209,7 +239,14 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel() 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 { if err != nil {
writeError(w, http.StatusBadGateway, "%v", err) writeError(w, http.StatusBadGateway, "%v", err)
return return
@@ -299,12 +336,23 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
return 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 // The inventory is re-read now so the plan is applied to current state
// rather than to whatever the browser last loaded. // rather than to whatever the browser last loaded.
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx) inv, err := src.docker.Inventory(invCtx)
cancel() cancel()
if err != nil { if err != nil {
release()
writeError(w, http.StatusBadGateway, "%v", err) writeError(w, http.StatusBadGateway, "%v", err)
return return
} }
@@ -315,13 +363,14 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
client, err := sshx.Dial(dialCtx, cfg, s.hosts) client, err := sshx.Dial(dialCtx, cfg, s.hosts)
dialCancel() dialCancel()
if err != nil { if err != nil {
release()
s.writeDialError(w, err) s.writeDialError(w, err)
return 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{ runner := &migrate.SSHRunner{
Src: s.docker, Src: src.docker,
Dst: sshx.NewRemoteDocker(client), Dst: sshx.NewRemoteDocker(client),
Containers: inv.Containers, Containers: inv.Containers,
Volumes: inv.Volumes, 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, j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error { func(ctx context.Context, j *job.Job) error {
defer release()
defer client.Close() 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) return runner.Run(ctx, j)
}) })
@@ -362,16 +413,25 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
return 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) invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx) inv, err := src.docker.Inventory(invCtx)
cancel() cancel()
if err != nil { if err != nil {
release()
writeError(w, http.StatusBadGateway, "%v", err) writeError(w, http.StatusBadGateway, "%v", err)
return return
} }
packager := &migrate.Packager{ packager := &migrate.Packager{
Src: s.docker, Src: src.docker,
Containers: inv.Containers, Containers: inv.Containers,
Volumes: inv.Volumes, Volumes: inv.Volumes,
Networks: inv.Networks, 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)) title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun, j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error { 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) res, err := packager.Run(ctx, j)
if err != nil { if err != nil {
return err return err
+40 -11
View File
@@ -11,9 +11,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"github.com/arescom/dockmv/internal/dkr"
"github.com/arescom/dockmv/internal/job" "github.com/arescom/dockmv/internal/job"
"github.com/arescom/dockmv/internal/sshx" "github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/internal/store" "github.com/arescom/dockmv/internal/store"
@@ -29,23 +29,31 @@ type Config struct {
DataDir string DataDir string
// PackageDir is where migration packages are written. // PackageDir is where migration packages are written.
PackageDir string PackageDir string
// DockerHost overrides the source daemon address. // DockerHost overrides the local source daemon address.
DockerHost string 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 is the embedded web app; nil disables the UI.
UI fs.FS UI fs.FS
// Logger receives request and error logs. // Logger receives request and error logs.
Logger *slog.Logger 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 { type Server struct {
cfg Config cfg Config
log *slog.Logger log *slog.Logger
docker *dkr.Client sources *store.Sources
conns *store.Connections conns *store.Connections
hosts *sshx.KnownHosts hosts *sshx.KnownHosts
jobs *job.Manager jobs *job.Manager
mux *http.ServeMux 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. // 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) 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")) conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -72,17 +76,35 @@ func New(cfg Config) (*Server, error) {
if err != nil { if err != nil {
return nil, err 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{ 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(), conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
} }
s.routes() s.routes()
return s, nil return s, nil
} }
// Close releases the Docker connection. // Close releases the connection to the current source.
func (s *Server) Close() error { return s.docker.Close() } func (s *Server) Close() error {
s.invalidateSource("")
return nil
}
// Handler returns the root HTTP handler. // Handler returns the root HTTP handler.
func (s *Server) Handler() 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", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes) 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("GET /api/connections", s.handleListConnections)
m.HandleFunc("POST /api/connections", s.handleSaveConnection) m.HandleFunc("POST /api/connections", s.handleSaveConnection)
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection) 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
}
+40
View File
@@ -6,6 +6,9 @@ package dkr
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"net/http"
"time"
"github.com/docker/docker/api/types/system" "github.com/docker/docker/api/types/system"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@@ -33,6 +36,43 @@ func New(host string) (*Client, error) {
return &Client{api: api, Endpoint: api.DaemonHost()}, nil 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 // API exposes the underlying SDK client for callers that need an operation
// this package does not wrap. // this package does not wrap.
func (c *Client) API() *client.Client { return c.api } func (c *Client) API() *client.Client { return c.api }
+170
View File
@@ -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()
}
+79
View File
@@ -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 }
+315
View File
@@ -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
}
+202
View File
@@ -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)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<title>DockMV</title> <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"> <link rel="stylesheet" crossorigin href="/assets/index-CKzWD9Xt.css">
</head> </head>
<body> <body>
+10 -1
View File
@@ -84,7 +84,7 @@ func serve(args []string) error {
token := fs.String("token", os.Getenv("DOCKMV_TOKEN"), "require this token on every request; \"auto\" generates one (default: $DOCKMV_TOKEN)") token := fs.String("token", os.Getenv("DOCKMV_TOKEN"), "require this token on every request; \"auto\" generates one (default: $DOCKMV_TOKEN)")
dataDir := fs.String("data-dir", defaultDataDir(), "directory for connections and trusted host keys") dataDir := fs.String("data-dir", defaultDataDir(), "directory for connections and trusted host keys")
pkgDir := fs.String("package-dir", "", "directory for migration packages (default <data-dir>/packages)") pkgDir := fs.String("package-dir", "", "directory for migration packages (default <data-dir>/packages)")
dockerHost := fs.String("docker-host", "", "source docker daemon (default: the DOCKER_HOST environment)") dockerHost := fs.String("docker-host", "", "local source docker daemon (default: the DOCKER_HOST environment)")
verbose := fs.Bool("v", false, "verbose logging") verbose := fs.Bool("v", false, "verbose logging")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: dockmv serve [flags]\n\nFlags:") fmt.Fprintln(os.Stderr, "Usage: dockmv serve [flags]\n\nFlags:")
@@ -93,6 +93,14 @@ func serve(args []string) error {
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
// A --docker-host given on the command line is an instruction for this run,
// and takes precedence over the source remembered from the last one.
dockerHostSet := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "docker-host" {
dockerHostSet = true
}
})
level := slog.LevelInfo level := slog.LevelInfo
if *verbose { if *verbose {
@@ -118,6 +126,7 @@ func serve(args []string) error {
DataDir: *dataDir, DataDir: *dataDir,
PackageDir: *pkgDir, PackageDir: *pkgDir,
DockerHost: *dockerHost, DockerHost: *dockerHost,
DockerHostSet: dockerHostSet,
UI: webui.FS(), UI: webui.FS(),
Logger: logger, Logger: logger,
} }
+57 -5
View File
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from './api' import { api } from './api'
import type { import type {
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, SourceResponse, TargetInventory, Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, Source, SourceResponse,
SourceStatus, TargetInventory,
} from './types' } from './types'
import { Notice } from './ui' import { Notice } from './ui'
import { Containers } from './Containers' import { Containers } from './Containers'
import { SourcePanel } from './SourcePanel'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { Jobs } from './Jobs' import { Jobs } from './Jobs'
import { Packages } from './Packages' import { Packages } from './Packages'
@@ -22,6 +24,9 @@ export default function App() {
// Volume sizes come from a separate, slower endpoint so the container list // Volume sizes come from a separate, slower endpoint so the container list
// can render immediately; they are merged into the rows when they arrive. // can render immediately; they are merged into the rows when they arrive.
const [sizes, setSizes] = useState<Record<string, number>>({}) const [sizes, setSizes] = useState<Record<string, number>>({})
const [sources, setSources] = useState<Source[]>([])
const [selectedSource, setSelectedSource] = useState<string>('local')
const [sourceStatus, setSourceStatus] = useState<SourceStatus | null>(null)
const [connections, setConnections] = useState<Connection[]>([]) const [connections, setConnections] = useState<Connection[]>([])
const [activeConn, setActiveConn] = useState<string>('') const [activeConn, setActiveConn] = useState<string>('')
const [targetInv, setTargetInv] = useState<TargetInventory | null>(null) const [targetInv, setTargetInv] = useState<TargetInventory | null>(null)
@@ -57,6 +62,40 @@ export default function App() {
} }
}, []) }, [])
const loadHealth = useCallback(async () => {
try {
const h = await api.health()
setHealth(h)
if (h.source) setSourceStatus(h.source)
} catch { /* the panel shows the source error on its own */ }
}, [])
const loadSources = useCallback(async () => {
try {
const r = await api.sources()
setSources(r.sources)
setSelectedSource(r.selected)
if (r.current) setSourceStatus(r.current)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
// selectSource lets its error escape so the panel can turn an untrusted host
// key into a fingerprint prompt, the same way the target does.
const selectSource = useCallback(async (id: string) => {
const st = await api.selectSource(id)
setSelectedSource(id)
setSourceStatus(st)
// The selections describe containers on the host that was selected before,
// so they are dropped rather than carried over to a different inventory.
setSel({})
setSizes({})
setError('')
await loadSource()
await loadHealth()
}, [loadSource, loadHealth])
const loadConnections = useCallback(async () => { const loadConnections = useCallback(async () => {
try { try {
const list = await api.connections() const list = await api.connections()
@@ -80,12 +119,13 @@ export default function App() {
}, []) }, [])
useEffect(() => { useEffect(() => {
api.health().then(setHealth).catch(() => undefined) loadHealth()
loadSources()
loadSource() loadSource()
loadConnections() loadConnections()
loadJobs() loadJobs()
loadPackages() loadPackages()
}, [loadSource, loadConnections, loadJobs, loadPackages]) }, [loadHealth, loadSources, loadSource, loadConnections, loadJobs, loadPackages])
// A slow poll keeps the job list current without holding a stream open for // A slow poll keeps the job list current without holding a stream open for
// every job; the detail view subscribes to its own live stream. // every job; the detail view subscribes to its own live stream.
@@ -138,7 +178,9 @@ export default function App() {
<div className="topbar-right"> <div className="topbar-right">
{health && ( {health && (
<span className="hostinfo"> <span className="hostinfo">
source <b>{source?.inventory.host || health.dockerHost}</b> source <b>{source?.inventory.host || sourceStatus?.name || health.dockerHost}</b>
{sourceStatus?.kind === 'ssh' && <> · over ssh</>}
{sourceStatus?.kind === 'docker' && <> · {sourceStatus.endpoint}</>}
{health.dockerVersion && <> · docker {health.dockerVersion}</>} {health.dockerVersion && <> · docker {health.dockerVersion}</>}
</span> </span>
)} )}
@@ -160,8 +202,10 @@ export default function App() {
{health && !health.ok && ( {health && !health.ok && (
<div style={{ padding: '10px 16px' }}> <div style={{ padding: '10px 16px' }}>
<Notice kind="err"> <Notice kind="err">
Cannot reach the source Docker daemon at <span className="mono">{health.dockerHost}</span> Cannot reach the source <b>{health.source?.name ?? 'docker daemon'}</b>
{health.dockerHost && <> at <span className="mono">{health.dockerHost}</span></>}
{health.dockerError && <> {health.dockerError}</>} {health.dockerError && <> {health.dockerError}</>}
<div className="small">Pick another source in the panel on the right.</div>
</Notice> </Notice>
</div> </div>
)} )}
@@ -192,6 +236,14 @@ export default function App() {
{view === 'containers' && ( {view === 'containers' && (
<aside className="sidebar"> <aside className="sidebar">
<SourcePanel
sources={sources}
selected={selectedSource}
status={sourceStatus}
selectSource={selectSource}
reload={loadSources}
onError={setError}
/>
<Sidebar <Sidebar
source={source} source={source}
plan={plan} plan={plan}
+3 -103
View File
@@ -4,6 +4,7 @@ import type {
Connection, HostKeyInfo, JobSnapshot, Options, Plan, PreviewResponse, SourceResponse, TargetInventory, Connection, HostKeyInfo, JobSnapshot, Options, Plan, PreviewResponse, SourceResponse, TargetInventory,
} from './types' } from './types'
import { Check, Field, humanBytes, Modal, Notice } from './ui' import { Check, Field, humanBytes, Modal, Notice } from './ui'
import { HostKeyDialog, SshFields } from './SshFields'
export function Sidebar({ export function Sidebar({
source, plan, includedCount, options, setOptions, source, plan, includedCount, options, setOptions,
@@ -302,37 +303,7 @@ export function Sidebar({
)} )}
{hostKey && ( {hostKey && (
<Modal <HostKeyDialog info={hostKey} onClose={() => setHostKey(null)} onTrust={trustHostKey} />
title="SSH host key"
onClose={() => setHostKey(null)}
footer={
<>
<button className="btn" onClick={() => setHostKey(null)}>cancel</button>
<button className="btn primary" onClick={trustHostKey}>
{hostKey.changed ? 'replace the stored key and trust' : 'trust this host'}
</button>
</>
}
>
<div className="stack">
{hostKey.changed && (
<Notice kind="err">
The key presented by this host is <b>different</b> from the one recorded earlier. This happens after a
reinstall but it is also what a machine-in-the-middle looks like. Only continue if you know why it
changed.
</Notice>
)}
{hostKey.trusted && !hostKey.changed && <Notice kind="ok">This host key is already trusted.</Notice>}
<div className="small muted">
Compare this with the output of <span className="mono">ssh-keyscan -t {hostKey.keyType} {hostKey.host}</span>{' '}
run on the target itself, or with <span className="mono">ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub</span>.
</div>
<div className="fingerprint">
{hostKey.keyType}<br />
{hostKey.fingerprint}
</div>
</div>
</Modal>
)} )}
{preview && <PreviewModal data={preview} onClose={() => setPreview(null)} />} {preview && <PreviewModal data={preview} onClose={() => setPreview(null)} />}
@@ -382,79 +353,8 @@ function ConnectionDialog({
} }
> >
<div className="stack" style={{ gap: 12 }}> <div className="stack" style={{ gap: 12 }}>
<div className="row" style={{ gap: 12 }}>
<Field label="label"><input type="text" value={c.name ?? ''} onChange={(e) => set('name', e.target.value)} /></Field> <Field label="label"><input type="text" value={c.name ?? ''} onChange={(e) => set('name', e.target.value)} /></Field>
<Field label="host"><input type="text" value={c.host ?? ''} onChange={(e) => set('host', e.target.value)} /></Field> <SshFields value={c} set={set} where="target" />
<div style={{ width: 90 }}>
<Field label="port">
<input type="number" value={c.port ?? 22} onChange={(e) => set('port', Number(e.target.value))} />
</Field>
</div>
</div>
<div className="row" style={{ gap: 12 }}>
<Field label="user"><input type="text" value={c.user ?? ''} onChange={(e) => set('user', e.target.value)} /></Field>
<Field label="authentication">
<select value={c.auth ?? 'password'} onChange={(e) => set('auth', e.target.value as Connection['auth'])}>
<option value="password">password</option>
<option value="key">private key</option>
<option value="agent">ssh agent</option>
</select>
</Field>
</div>
{c.auth === 'password' && (
<Field label="password">
<input type="password" value={c.password ?? ''} onChange={(e) => set('password', e.target.value)} />
</Field>
)}
{c.auth === 'key' && (
<>
<Field label="private key path on this machine (leave empty to paste the key below)">
<input type="text" placeholder="/root/.ssh/id_ed25519" value={c.privateKeyPath ?? ''} onChange={(e) => set('privateKeyPath', e.target.value)} />
</Field>
<Field label="or paste the private key">
<textarea rows={5} value={c.privateKey ?? ''} onChange={(e) => set('privateKey', e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
</Field>
<Field label="passphrase (if the key is encrypted)">
<input type="password" value={c.passphrase ?? ''} onChange={(e) => set('passphrase', e.target.value)} />
</Field>
</>
)}
{c.auth === 'agent' && (
<div className="small muted">
Uses the agent at <span className="mono">$SSH_AUTH_SOCK</span> of the process running dockmv.
</div>
)}
<Check
checked={c.sudo ?? false}
onChange={(v) => set('sudo', v)}
label="run docker through sudo -n on the target"
title="Needed when the login user is not in the docker group. sudo must not ask for a password."
/>
<Field label="docker command on the target (optional)">
<input type="text" placeholder="docker" value={c.dockerCmd ?? ''} onChange={(e) => set('dockerCmd', e.target.value)} />
</Field>
<Check
checked={c.saveSecrets ?? false}
onChange={(v) => set('saveSecrets', v)}
label="remember the password / key on disk"
/>
{c.saveSecrets ? (
<Notice kind="warn">
Credentials are stored in plain text in the connections file, readable only by this user. Leave this off to
keep them in memory for this session only.
</Notice>
) : (
<div className="small faint">
Credentials stay in memory and are lost when dockmv restarts.
</div>
)}
</div> </div>
</Modal> </Modal>
) )
+283
View File
@@ -0,0 +1,283 @@
import { useState } from 'react'
import { api, ApiError } from './api'
import type { Connection, HostKeyInfo, Source, SourceKind, SourceStatus } from './types'
import { Field, Modal, Notice } from './ui'
import { HostKeyDialog, SshFields } from './SshFields'
/**
* SourcePanel picks the host containers are read from: the daemon dockmv runs
* next to, another daemon by address, or a remote host over SSH.
*/
export function SourcePanel({
sources, selected, status, selectSource, reload, onError,
}: {
sources: Source[]
selected: string
status: SourceStatus | null
/** Switches the whole UI to another source; rejects so the caller can react. */
selectSource: (id: string) => Promise<void>
reload: () => Promise<void>
onError: (msg: string) => void
}) {
const [editing, setEditing] = useState<Partial<Source> | null>(null)
const [hostKey, setHostKey] = useState<HostKeyInfo | null>(null)
const [busy, setBusy] = useState('')
const [trustFor, setTrustFor] = useState('')
const current = sources.find((s) => s.id === selected)
const isLocal = !current || current.kind === 'local'
const isSSH = current?.kind === 'ssh'
async function withBusy(what: string, id: string, fn: () => Promise<void>) {
setBusy(what)
try {
await fn()
} catch (e) {
// An untrusted host key is a decision for the operator, not an error.
if (e instanceof ApiError && e.needsTrust) {
setTrustFor(id)
await showHostKey(id)
} else {
onError(e instanceof Error ? e.message : String(e))
}
} finally {
setBusy('')
}
}
async function showHostKey(id: string) {
try {
setHostKey(await api.probeSource(id))
} catch (e) {
onError(e instanceof Error ? e.message : String(e))
}
}
async function trustHostKey() {
const id = trustFor || selected
if (!hostKey) return
await withBusy('trust', id, async () => {
await api.trustSource(id, hostKey.fingerprint)
setHostKey(null)
await selectSource(id)
})
}
return (
<div className="section">
<h3>source host</h3>
<div className="stack">
<div className="row">
<select
value={selected}
disabled={!!busy}
onChange={(e) => {
const id = e.target.value
void withBusy('select', id, () => selectSource(id))
}}
>
{sources.map((s) => (
<option key={s.id} value={s.id}>{sourceLabel(s)}</option>
))}
</select>
<button
className="btn tiny"
onClick={() => setEditing({ kind: 'ssh', ssh: { port: 22, auth: 'password', saveSecrets: false, sudo: false } as Connection })}
>
new
</button>
</div>
<div className="row wrap" style={{ gap: 6 }}>
<button
className="btn tiny"
disabled={!!busy}
onClick={() => void withBusy('select', selected, () => selectSource(selected))}
>
{busy === 'select' ? 'connecting…' : 'reconnect'}
</button>
{!isLocal && <button className="btn tiny" onClick={() => setEditing(current)}>edit</button>}
{isSSH && <button className="btn tiny" onClick={() => { setTrustFor(selected); void showHostKey(selected) }}>host key</button>}
{!isLocal && (
<button
className="btn tiny danger"
onClick={() => {
if (!current || !confirm(`Delete source "${current.name}"?`)) return
void withBusy('del', current.id, async () => {
await api.deleteSource(current.id)
await reload()
// Deleting the source in use drops back to the local daemon.
if (selected === current.id) await selectSource('local')
})
}}
>
delete
</button>
)}
</div>
{status?.error && <Notice kind="err">{status.error}</Notice>}
{status && !status.error && (
<dl className="kv">
<dt>reached by</dt><dd className="mono">{status.endpoint}</dd>
{status.dockerVersion && <><dt>docker</dt><dd>{status.dockerVersion}</dd></>}
</dl>
)}
{isSSH && (
<div className="small faint">
Data is streamed through dockmv: source this host target. A local source moves it in one hop.
</div>
)}
</div>
{editing && (
<SourceDialog
initial={editing}
onClose={() => setEditing(null)}
onSaved={async (s) => {
setEditing(null)
// The list is refreshed first: selecting an id the dropdown does not
// know about yet would leave it blank.
await reload()
await withBusy('select', s.id, () => selectSource(s.id))
}}
onError={onError}
/>
)}
{hostKey && (
<HostKeyDialog info={hostKey} onClose={() => setHostKey(null)} onTrust={trustHostKey} />
)}
</div>
)
}
function sourceLabel(s: Source): string {
switch (s.kind) {
case 'local':
return `${s.name} (local docker)`
case 'docker':
return `${s.name} (${s.dockerHost})`
default:
return `${s.name} (ssh ${s.ssh?.user}@${s.ssh?.host})`
}
}
function SourceDialog({
initial, onClose, onSaved, onError,
}: {
initial: Partial<Source>
onClose: () => void
onSaved: (s: Source) => void | Promise<void>
onError: (m: string) => void
}) {
const [s, setS] = useState<Partial<Source>>(initial)
const [saving, setSaving] = useState(false)
const kind: SourceKind = s.kind ?? 'ssh'
const ssh = (s.ssh ?? {}) as Partial<Connection>
function setSSH<K extends keyof Connection>(k: K, v: Connection[K]) {
setS((prev) => ({ ...prev, ssh: { ...(prev.ssh as Connection), [k]: v } as Connection }))
}
const valid = kind === 'ssh' ? !!ssh.host && !!ssh.user : !!s.dockerHost
return (
<Modal
title={initial.id ? `Edit ${initial.name}` : 'New source host'}
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose}>cancel</button>
<button
className="btn primary"
disabled={saving || !valid}
onClick={async () => {
setSaving(true)
try {
// The body is built field by field: the API rejects unknown ones.
await onSaved(await api.saveSource(payload(s, kind)))
} catch (e) {
onError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}}
>
{saving ? 'saving…' : 'save'}
</button>
</>
}
>
<div className="stack" style={{ gap: 12 }}>
<div className="row" style={{ gap: 12 }}>
<Field label="label">
<input type="text" value={s.name ?? ''} onChange={(e) => setS((p) => ({ ...p, name: e.target.value }))} />
</Field>
<Field label="reached by">
<select value={kind} onChange={(e) => setS((p) => ({ ...p, kind: e.target.value as SourceKind }))}>
<option value="ssh">ssh remote host, driven through its docker CLI</option>
<option value="docker">docker address a daemon this host can reach</option>
</select>
</Field>
</div>
{kind === 'docker' ? (
<>
<Field label="docker address">
<input
type="text"
placeholder="tcp://10.0.0.5:2375"
value={s.dockerHost ?? ''}
onChange={(e) => setS((p) => ({ ...p, dockerHost: e.target.value }))}
/>
</Field>
<div className="small muted">
Any address the docker CLI accepts: <span className="mono">tcp://host:2375</span>, or another socket with{' '}
<span className="mono">unix:///path/docker.sock</span>. A TLS-protected daemon uses the certificates from{' '}
<span className="mono">DOCKER_CERT_PATH</span> in dockmv's own environment.
</div>
<Notice kind="warn">
A plain <span className="mono">tcp://</span> daemon is unauthenticated: anyone who can reach that port is
root on that host. Prefer an ssh source unless the port is already protected.
</Notice>
</>
) : (
<>
<SshFields value={ssh} set={setSSH} where="source host" />
<div className="small muted">
Needs <span className="mono">sshd</span> and a docker CLI of 18.09 or newer on that host the API is
tunnelled through <span className="mono">docker system dial-stdio</span>. Nothing is installed.
</div>
</>
)}
</div>
</Modal>
)
}
/** payload keeps the request to the fields the server knows about. */
function payload(s: Partial<Source>, kind: SourceKind): Partial<Source> {
const out: Partial<Source> = { id: s.id, name: s.name, kind }
if (kind === 'docker') {
out.dockerHost = s.dockerHost
return out
}
const c = (s.ssh ?? {}) as Partial<Connection>
out.ssh = {
host: c.host ?? '',
port: c.port ?? 22,
user: c.user ?? '',
auth: c.auth ?? 'password',
password: c.password,
privateKey: c.privateKey,
privateKeyPath: c.privateKeyPath,
passphrase: c.passphrase,
sudo: c.sudo ?? false,
dockerCmd: c.dockerCmd,
saveSecrets: c.saveSecrets ?? false,
} as Connection
return out
}
+138
View File
@@ -0,0 +1,138 @@
import type { Connection, HostKeyInfo } from './types'
import { Check, Field, Modal, Notice } from './ui'
/**
* SshFields is the credential form shared by target connections and SSH
* sources: both are the same sshx.Config on the server, so they are edited the
* same way. `where` only changes the wording.
*/
export function SshFields({
value, set, where,
}: {
value: Partial<Connection>
set: <K extends keyof Connection>(k: K, v: Connection[K]) => void
where: 'target' | 'source host'
}) {
return (
<>
<div className="row" style={{ gap: 12 }}>
<Field label="host"><input type="text" value={value.host ?? ''} onChange={(e) => set('host', e.target.value)} /></Field>
<div style={{ width: 90 }}>
<Field label="port">
<input type="number" value={value.port ?? 22} onChange={(e) => set('port', Number(e.target.value))} />
</Field>
</div>
</div>
<div className="row" style={{ gap: 12 }}>
<Field label="user"><input type="text" value={value.user ?? ''} onChange={(e) => set('user', e.target.value)} /></Field>
<Field label="authentication">
<select value={value.auth ?? 'password'} onChange={(e) => set('auth', e.target.value as Connection['auth'])}>
<option value="password">password</option>
<option value="key">private key</option>
<option value="agent">ssh agent</option>
</select>
</Field>
</div>
{value.auth === 'password' && (
<Field label="password">
<input type="password" value={value.password ?? ''} onChange={(e) => set('password', e.target.value)} />
</Field>
)}
{value.auth === 'key' && (
<>
<Field label="private key path on this machine (leave empty to paste the key below)">
<input type="text" placeholder="/root/.ssh/id_ed25519" value={value.privateKeyPath ?? ''} onChange={(e) => set('privateKeyPath', e.target.value)} />
</Field>
<Field label="or paste the private key">
<textarea rows={5} value={value.privateKey ?? ''} onChange={(e) => set('privateKey', e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
</Field>
<Field label="passphrase (if the key is encrypted)">
<input type="password" value={value.passphrase ?? ''} onChange={(e) => set('passphrase', e.target.value)} />
</Field>
</>
)}
{value.auth === 'agent' && (
<div className="small muted">
Uses the agent at <span className="mono">$SSH_AUTH_SOCK</span> of the process running dockmv.
</div>
)}
<Check
checked={value.sudo ?? false}
onChange={(v) => set('sudo', v)}
label={`run docker through sudo -n on the ${where}`}
title="Needed when the login user is not in the docker group. sudo must not ask for a password."
/>
<Field label={`docker command on the ${where} (optional)`}>
<input type="text" placeholder="docker" value={value.dockerCmd ?? ''} onChange={(e) => set('dockerCmd', e.target.value)} />
</Field>
<Check
checked={value.saveSecrets ?? false}
onChange={(v) => set('saveSecrets', v)}
label="remember the password / key on disk"
/>
{value.saveSecrets ? (
<Notice kind="warn">
Credentials are stored in plain text in dockmv's data directory, readable only by this user. Leave this off to
keep them in memory for this session only.
</Notice>
) : (
<div className="small faint">
Credentials stay in memory and are lost when dockmv restarts.
</div>
)}
</>
)
}
/**
* HostKeyDialog puts an unknown or changed SSH fingerprint in front of the
* operator. It is used for both source and target hosts.
*/
export function HostKeyDialog({
info, onClose, onTrust,
}: {
info: HostKeyInfo
onClose: () => void
onTrust: () => void
}) {
return (
<Modal
title="SSH host key"
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose}>cancel</button>
<button className="btn primary" onClick={onTrust}>
{info.changed ? 'replace the stored key and trust' : 'trust this host'}
</button>
</>
}
>
<div className="stack">
{info.changed && (
<Notice kind="err">
The key presented by this host is <b>different</b> from the one recorded earlier. This happens after a
reinstall but it is also what a machine-in-the-middle looks like. Only continue if you know why it
changed.
</Notice>
)}
{info.trusted && !info.changed && <Notice kind="ok">This host key is already trusted.</Notice>}
<div className="small muted">
Compare this with the output of <span className="mono">ssh-keyscan -t {info.keyType} {info.host}</span>{' '}
run on the host itself, or with <span className="mono">ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub</span>.
</div>
<div className="fingerprint">
{info.keyType}<br />
{info.fingerprint}
</div>
</div>
</Modal>
)
}
+9 -1
View File
@@ -1,6 +1,6 @@
import type { import type {
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan, Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
Preflight, PreviewResponse, SourceResponse, TargetInventory, Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory,
} from './types' } from './types'
// The token, when the server requires one, arrives as a query parameter the // The token, when the server requires one, arrives as a query parameter the
@@ -65,6 +65,14 @@ export const api = {
source: () => request<SourceResponse>('/api/source'), source: () => request<SourceResponse>('/api/source'),
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'), volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
sources: () => request<SourcesResponse>('/api/sources'),
saveSource: (s: Partial<Source>) => post<Source>('/api/sources', s),
deleteSource: (id: string) => request<void>(`/api/sources/${id}`, { method: 'DELETE' }),
selectSource: (id: string) => post<SourceStatus>(`/api/sources/${id}/select`),
probeSource: (id: string) => post<HostKeyInfo>(`/api/sources/${id}/probe`),
trustSource: (id: string, fingerprint: string) =>
post<{ trusted: boolean }>(`/api/sources/${id}/trust`, { fingerprint }),
connections: () => request<Connection[]>('/api/connections'), connections: () => request<Connection[]>('/api/connections'),
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c), saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }), deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
+30
View File
@@ -120,6 +120,35 @@ export interface SourceResponse {
options: Options options: Options
} }
/** How a source daemon is reached. 'local' is the built-in, unremovable one. */
export type SourceKind = 'local' | 'docker' | 'ssh'
export interface Source {
id: string
name: string
kind: SourceKind
/** Daemon address for the 'docker' kind, e.g. tcp://10.0.0.5:2375. */
dockerHost?: string
/** Remote host for the 'ssh' kind; the same shape as a target connection. */
ssh?: Connection
}
export interface SourceStatus {
id: string
name: string
kind: SourceKind
endpoint: string
dockerVersion?: string
connected: boolean
error?: string
}
export interface SourcesResponse {
sources: Source[]
selected: string
current: SourceStatus | null
}
export type AuthMethod = 'password' | 'key' | 'agent' export type AuthMethod = 'password' | 'key' | 'agent'
export interface Connection { export interface Connection {
@@ -253,6 +282,7 @@ export interface Health {
dockerHost: string dockerHost: string
dockerVersion?: string dockerVersion?: string
dockerError?: string dockerError?: string
source?: SourceStatus
packageDir: string packageDir: string
dataDir: string dataDir: string
knownHosts: string knownHosts: string