4 Commits
Author SHA1 Message Date
kawa 17a83e737f Merge branch 'main' of https://git.azuze.fr/kawa/DockMV
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 10:20:45 +02:00
kawaandClaude Haiku 4.5 9b354636bb 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>
2026-08-13 10:19:47 +02:00
kawa 0a97b78d63 Added the git wkflow
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 09:05:56 +02:00
kawaandClaude Haiku 4.5 05db8bfeb9 Restructure README for clarity and navigation
Reorganize installation, dependencies, and usage sections. Collapse implementation details
into collapsible sections (How the data is moved, Safety, Migration package, Development).
Consolidate target host requirements earlier. Clarify emphasis on key features ("and their data",
the ssh-direct behavior, what is and is not preserved).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-12 16:31:49 +02:00
20 changed files with 2047 additions and 295 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Sync Gitea releases to GitHub
on:
push:
branches:
- main
workflow_dispatch: {}
env:
GITEA_URL: https://git.azuze.fr
GITEA_OWNER: kawa
GITEA_REPO: DockMV
jobs:
sync-releases:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Install jq
run: sudo apt-get update && sudo apt-get install -y jq
- name: Fetch Gitea releases and sync to GitHub
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER=()
if [ -n "${GITEA_TOKEN:-}" ]; then
AUTH_HEADER=(-H "Authorization: token ${GITEA_TOKEN}")
fi
# Récupère toutes les releases Gitea (pagination simple, 50 max, ajuster si besoin)
releases=$(curl -sf "${AUTH_HEADER[@]}" \
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases?limit=50")
echo "$releases" | jq -c '.[]' | while read -r release; do
tag=$(echo "$release" | jq -r '.tag_name')
name=$(echo "$release" | jq -r '.name // .tag_name')
body=$(echo "$release" | jq -r '.body // ""')
prerelease=$(echo "$release" | jq -r '.prerelease')
draft=$(echo "$release" | jq -r '.draft')
# Skip si la release existe déjà sur GitHub
if gh release view "$tag" >/dev/null 2>&1; then
echo "Release $tag existe déjà sur GitHub, on passe."
continue
fi
echo "Création de la release $tag sur GitHub..."
flags=()
[ "$prerelease" = "true" ] && flags+=(--prerelease)
[ "$draft" = "true" ] && flags+=(--draft)
# Le tag doit exister sur le repo GitHub ; s'il n'existe pas encore,
# on utilise --notes-file avec target par défaut (branche par défaut)
gh release create "$tag" \
--title "$name" \
--notes "$body" \
"${flags[@]}"
# Télécharge et attache les assets de la release Gitea
asset_count=$(echo "$release" | jq '.assets | length')
if [ "$asset_count" -gt 0 ]; then
tmpdir=$(mktemp -d)
echo "$release" | jq -c '.assets[]' | while read -r asset; do
asset_name=$(echo "$asset" | jq -r '.name')
asset_url=$(echo "$asset" | jq -r '.browser_download_url')
echo "Téléchargement de $asset_name..."
curl -sfL "${AUTH_HEADER[@]}" -o "${tmpdir}/${asset_name}" "$asset_url"
gh release upload "$tag" "${tmpdir}/${asset_name}" --clobber
done
rm -rf "$tmpdir"
fi
done
+169 -134
View File
@@ -1,6 +1,6 @@
<p align="center"><img src="assets/dockmv-logo-full.png" alt="DockMV" width="480"></p>
Move Docker containers — and their data — from one host to another, from a web UI, in a few clicks.
Move Docker containers — **and their data** — from one host to another, from a web UI, in a few clicks.
It handles the whole container, not just the image: named volumes, anonymous volumes, bind mounts,
user-defined networks, published ports, environment, capabilities, restart policy, healthchecks and
@@ -10,148 +10,202 @@ Two ways to move things:
| Mode | What happens | When to use it |
| --- | --- | --- |
| **Host to host over SSH** | The source connects to the target over SSH and streams everything straight into the target's `docker cp` / `docker load`. Nothing touches disk in between. | The two hosts can reach each other. |
| **Host to host over SSH** | The source streams everything straight into the target's `docker cp` / `docker load`. Nothing touches disk in between. | The two hosts can reach each other. |
| **Migration package** | Builds a self-contained folder or `.tar` holding the data, the images, and a plain-bash `install.sh`. Carry it on a disk, run the script on the target. | Air-gapped targets, or when you want the move reviewed and replayed later. |
The target needs **nothing installed**: no agent, no Python, no Go. Just `sshd`, a working `docker`
CLI, `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.
---
## Quick start
## Install
### Run it in a container (recommended)
Run DockMV on the **source** host (the one holding the containers to move), or anywhere that can
reach it — see [sources](#sources).
On the **source** host:
### Docker Compose — recommended
```bash
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
docker compose up -d --build
docker compose up -d
docker compose logs dockmv # prints the URL, including the access token
```
Then open the printed URL. It binds to `127.0.0.1` only; reach it from your laptop with a tunnel:
Open the printed URL. It binds to `127.0.0.1` only; reach it from your laptop with a tunnel:
```bash
ssh -L 8080:127.0.0.1:8080 you@source-host
```
### Run it bare-metal
Uses the published image `git.azuze.fr/kawa/dockmv:latest`. Pin a version with `VERSION=v1.2.0 docker compose up -d`,
and set a fixed token with `DOCKMV_TOKEN` in the compose file to keep the same URL across restarts.
The binary is fully static and embeds the web UI, so there is nothing to install alongside it.
### Prebuilt binary
Grab the archive for your platform from the [releases](https://git.azuze.fr/kawa/DockMV/releases)
(`dockmv-<os>-<arch>.tar.gz`, `.zip` on Windows). The binary is static and embeds the web UI —
nothing to install alongside it.
```bash
make build # needs Go 1.25+ and Node 20+ ... or just `go build .` if you skip the UI rebuild
tar xzf dockmv-linux-amd64.tar.gz
./dockmv-linux-amd64 serve
```
### From source
```bash
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
make build # rebuilds the UI, then the binary
./dockmv serve
```
Prebuilt for several platforms:
`go build .` alone also works — the built UI is committed, so the Node toolchain is optional.
```bash
make release # dist/dockmv-linux-amd64, -linux-arm64, -darwin-arm64, -windows-amd64
```
> DockMV needs access to the Docker socket on the source host: run it as a user in the `docker`
> group, or as root. That is equivalent to root on that host, so keep the UI on loopback.
`dockmv` needs access to the Docker socket on the source host, so run it as a user in the
`docker` group (or as root).
---
## Dependencies
### Source host — where the containers are
| Requirement | Notes |
| --- | --- |
| Docker daemon + access to `/var/run/docker.sock` | reads containers and streams their data |
| Docker Compose | only for the compose install |
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
| Requirement | Why |
| --- | --- |
| `sshd`, reachable from the source | transport |
| `docker` CLI + a working daemon | everything |
| the login user can use docker | either in the `docker` group, or tick *run docker through `sudo -n`* |
| `gzip` | compressed transfers; without it the tool falls back to uncompressed |
| `bash` | only for the migration package installer |
The **connect** button runs a preflight and tells you which of these are missing, plus the target's
free disk space and architecture.
### Building from source
| Tool | Version |
| --- | --- |
| Go | 1.25+ |
| Node | 20+ (22 in CI) — only to rebuild the UI |
| `make` | optional, wraps the two above |
| PowerShell 7+ | only for `make publish` / `make release` |
Libraries: [`docker/docker`](https://github.com/docker/docker) v28.3.3 and `golang.org/x/crypto`
on the Go side; React 19, Vite 7 and TypeScript 5.9 on the UI side. That is the whole list.
---
## Using it
1. **Containers tab** — everything on the source host, grouped by compose project.
Tick the ones to move. Use *select all*, *select running*, or the compose-project checkbox for
batch selection.
2. **Expand a row** (`▸`) to choose per-container details: the name on the target, whether the image
is pulled or transferred, whether networks and ports come along, and — per mount — whether to
**copy the data**, **create it empty**, or **not mount it at all**. Bind mounts can be relocated to
a different path on the target; named volumes can be renamed.
3. **Apply to selected** in the toolbar does the same thing to every selected container at once
(*copy all data*, *skip binds*, *image: pull on target*, …).
4. **Right panel** — add the target host, hit *connect*, review the options, then either
**migrate over SSH** or **build a package**.
5. **Preview the commands** shows the exact `docker` invocations that will run on the target. Nothing
is hidden.
1. **Source host** — top of the right panel. Defaults to the daemon DockMV runs next to; pick another
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**.
Bind mounts can be relocated; named volumes renamed.
4. **Apply to selected** does the same thing to every selected container at once.
5. **Right panel** — add the target host, *connect*, then **migrate over SSH** or **build a package**.
6. **Jobs tab** — live progress per container and per mount, with the full command log.
Start with **dry run** ticked. It performs every check and prints every command without changing
anything on 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.
---
### Sources
## How the data is actually moved
Three kinds, all interchangeable once selected — the container list, the preview, the migration and
the package build all read from whichever source is active:
The interesting part is that there is exactly **one** mechanism for every kind of data location:
| 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>
<summary><b>How the data is actually moved</b></summary>
One mechanism for every kind of data location:
```
source daemon ──CopyFromContainer(/mount/path)──▶ tar stream ──gzip──▶ ssh ──▶ docker cp -a - ctr:/parent
```
The source container's own mount path is read through the Docker archive API — the same thing
`docker cp` uses. That means:
The mount is read through the Docker archive API — the same thing `docker cp` uses. So:
- named volumes, anonymous volumes and bind mounts are all handled identically;
- no helper image is pulled, and the container's image does not need `tar` inside it;
- named volumes, anonymous volumes and bind mounts are handled identically;
- no helper image is pulled, and the image does not need `tar` inside it;
- it works whether the container is running or stopped;
- file ownership, permissions, symlinks and hardlinks are preserved (`docker cp -a`).
- ownership, permissions, symlinks and hardlinks are preserved (`docker cp -a`).
On the target the container is **created first, started last**. Creating it is what makes Docker
materialise the named volumes and bind directories; the data is then copied into the stopped
container, and only then is it started.
On the target the container is **created first, started last** — creating it is what makes Docker
materialise the volumes and bind directories; data is copied into the stopped container, then it
starts. Mounts declared **read-only** get a throwaway container (never started) with the volume
attached writable, and it is removed straight after.
A mount the container declares **read-only** cannot be written through the container itself. For
those, a throwaway container is created (never started) with the same volume attached writable, the
data is copied into it, and it is removed straight after.
**Faithfully reproduced:** image (pull or layer transfer), command, entrypoint, environment, labels,
working directory, user, hostname, published and exposed ports, all mount types, user-defined
networks with subnets and aliases, DNS, extra hosts, capabilities, devices, sysctls, ulimits,
security options, restart policy, stop signal and timeout, healthcheck, log driver, memory/CPU/pids
limits, privileged, read-only rootfs, init, and the PID/IPC/UTS/userns modes.
### What is faithfully reproduced
Settings that come from the **image** are deliberately not re-emitted, so the recreated container
carries only genuine run-time overrides and keeps working when the image is updated.
Image (by pull or by layer transfer), command, entrypoint, environment, labels, working directory,
user, hostname, published and exposed ports, all mount types, user-defined networks with their
subnets and the container's aliases, DNS settings, extra hosts, capabilities, devices, sysctls,
ulimits, security options, restart policy, stop signal and timeout, healthcheck, log driver and
options, memory/CPU/pids limits, privileged, read-only rootfs, init, and the PID/IPC/UTS/userns
modes.
**What it will not do:**
Settings that come from the **image** are deliberately not re-emitted — the recreated container
carries only genuine run-time overrides, so it stays readable and keeps working when the image is
later updated.
- `--rm` is never reapplied — a container that deletes itself cannot be inspected.
- `--volumes-from` and `--network container:other` are not reproduced; you are warned.
- Swarm services are out of scope. Plain containers only.
- **Live databases**: copying a running database's files is crash-consistent at best. The default
stops the source container while copying — leave it on, or migrate a dump instead.
- **Cross-architecture**: an `amd64` image will not run on `arm64`. The preflight shows the target's arch.
### What it will not do for you
</details>
- **`--rm` is never reapplied.** A migrated container that deletes itself cannot be inspected.
- **`--volumes-from` is not reproduced.** You are warned; migrate the other container and mount
explicitly.
- **`--network container:other`** requires the other container to be migrated too. You are warned.
- **Swarm services** are out of scope. This tool moves plain containers.
- **Live databases**: copying a running database's files gives you a crash-consistent snapshot at
best. The default is to stop the source container while copying — leave it on. For anything you
really care about, take a dump instead and migrate that.
- **Cross-architecture moves**: an `amd64` image will not run on an `arm64` target. The preflight
shows the target's architecture; check it.
---
## Safety
<details>
<summary><b>Safety</b></summary>
The tool can stop containers and read every volume on the host, so it is treated as a privileged
admin tool:
- It binds to **`127.0.0.1` by default**. Binding anywhere else automatically generates an access
token and prints it.
- **SSH host keys are verified** exactly like OpenSSH. An unknown key is refused until you approve
the fingerprint in the UI; a *changed* key is refused outright until you explicitly replace it.
Trusted keys go to `<data-dir>/known_hosts`.
- **Credentials are not persisted unless you ask.** By default the password or key lives in memory
for the session. Ticking *remember* writes it to `<data-dir>/connections.json`, mode `0600`.
- **Nothing on the target is overwritten by default.** If a container name already exists the item
fails; you choose *skip*, *rename* or *replace* explicitly. An existing **volume** is reused and
merged into, never silently deleted, unless you pick *replace*.
- 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, for sources as well as targets. An unknown key is
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
`<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;
you pick *skip*, *rename* or *replace*. An existing volume is reused and merged into, never
silently deleted, unless you pick *replace*.
- Every command that runs on the target is echoed into the job log.
- Bind mounts of `/var/run/docker.sock`, `/proc`, `/sys`, `/dev` and `/` are flagged, and a mount at
`/` is refused outright.
- Bind mounts of `/var/run/docker.sock`, `/proc`, `/sys`, `/dev` and `/` are flagged; a mount at `/` is refused.
---
</details>
## The migration package
<details>
<summary><b>The migration package</b></summary>
`build package` produces:
@@ -171,8 +225,8 @@ On the target:
./install.sh # restore
```
The installer never parses the manifest — every command is written out literally, so it can be read
and audited before running. It checksums each payload before feeding it to Docker, and supports:
The installer never parses the manifest — every command is written out literally, so it can be
audited before running. It checksums each payload, and supports:
```
--dry-run print every command without changing anything
@@ -186,24 +240,10 @@ and audited before running. It checksums each payload before feeding it to Docke
--sudo prefix docker with sudo -n
```
---
</details>
## Target host requirements
| Requirement | Why |
| --- | --- |
| `sshd`, reachable from the source | transport |
| `docker` CLI + a working daemon | everything |
| the login user can use docker | either in the `docker` group, or tick *run docker through `sudo -n`* |
| `gzip` | compressed transfers; without it the tool falls back to uncompressed |
| `bash` | only for the migration package installer |
The **connect** button runs a preflight and tells you which of these are missing, plus the target's
free disk space and architecture.
---
## Command line
<details>
<summary><b>Command line and HTTP API</b></summary>
```
dockmv [serve] [flags] start the web interface (default)
@@ -216,9 +256,10 @@ dockmv version
```
--addr string address to listen on (default "127.0.0.1:8080")
--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)
--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
```
@@ -228,16 +269,18 @@ dockmv version
dockmv inspect --sizes | jq '.containers[] | {name, image, mounts}'
```
---
## HTTP API
Everything the UI does is available over HTTP. Pass the token as `X-Auth-Token` when one is set.
```
GET /api/health
GET /api/source inventory + default selections
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
POST /api/connections
DELETE /api/connections/{id}
@@ -254,15 +297,18 @@ POST /api/jobs/{id}/cancel
GET /api/packages, /api/packages/{name}/download
```
---
</details>
## Development
<details>
<summary><b>Development</b></summary>
```
web/ React + TypeScript UI (vite)
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/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/job/ progress tracking for long-running work
internal/api/ HTTP handlers and SSE
@@ -276,30 +322,20 @@ make ui # rebuild the embedded UI
cd web && npm run dev # UI dev server on :5173, proxying /api to :8080
```
### Verifying it works
`internal/webui/dist` is committed so that a plain `go build .` produces a working binary without a
Node toolchain. Rerun `make ui` after changing anything under `web/`.
On a Linux host with Docker (a VM is fine):
End-to-end tests, on a Linux host with Docker (a VM is fine):
```bash
go test ./... # unit tests: command rendering, plan resolution,
# and the generated installer, checked with bash
go test -tags e2e ./test/... -v # end to end, against the real daemon:
# creates a container with a named volume, a
# read-only bind mount and an anonymous volume,
# writes files into all three, builds a package,
# runs the generated install.sh, then reads the
# files back out of the restored container
go test -tags e2e ./test/... -v # end to end, against the real daemon
```
The e2e tests restore onto the same daemon under a suffixed name and clean up after
themselves, so a single machine is enough:
- `TestPackageRoundTrip` — builds a package and runs the generated `install.sh` for real.
- `TestSSHMigration` — drives the host-to-host engine over a genuine SSH connection to
`127.0.0.1`, so the whole transport (ssh, gzip streaming, the target's docker CLI, the
staging container for read-only mounts, the verify step) is exercised. It needs
`DM_SSH_HOST`, `DM_SSH_USER` and `DM_SSH_KEY`, and skips without them:
- `TestSSHMigration` — drives the host-to-host engine over a genuine SSH connection to `127.0.0.1`,
exercising the whole transport. Needs `DM_SSH_HOST`, `DM_SSH_USER` and `DM_SSH_KEY`, skips without them:
```bash
ssh-keygen -t ed25519 -N '' -f ~/.ssh/dm_loop
@@ -308,8 +344,7 @@ themselves, so a single machine is enough:
go test -tags e2e ./test/... -run TestSSHMigration -v
```
Both were run against Debian 13 with Docker 29.7.2, alongside a browser pass over the web
UI covering the trust prompt, a batch migration and a package build.
They restore onto the same daemon under a suffixed name and clean up, so one machine is enough.
Both were run against Debian 13 with Docker 29.7.2, alongside a browser pass over the web UI.
`internal/webui/dist` is committed so that a plain `go build .` produces a working binary without a
Node toolchain. Rerun `make ui` after changing anything under `web/`.
</details>
+76 -14
View File
@@ -21,24 +21,40 @@ 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)
}
// 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)
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
+40 -11
View File
@@ -11,9 +11,9 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/arescom/dockmv/internal/dkr"
"github.com/arescom/dockmv/internal/job"
"github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/internal/store"
@@ -29,23 +29,31 @@ type Config struct {
DataDir string
// PackageDir is where migration packages are written.
PackageDir string
// DockerHost overrides the source daemon address.
// DockerHost overrides the local source daemon address.
DockerHost string
// DockerHostSet reports that DockerHost was given explicitly on the command
// line. It then wins over the source remembered from the last run.
DockerHostSet bool
// UI is the embedded web app; nil disables the UI.
UI fs.FS
// Logger receives request and error logs.
Logger *slog.Logger
}
// Server ties the Docker client, connection store and job manager to HTTP.
// Server ties the source daemon, connection store and job manager to HTTP.
type Server struct {
cfg Config
log *slog.Logger
docker *dkr.Client
sources *store.Sources
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
mux *http.ServeMux
// srcMu guards cur, the connection to the selected source. It is opened on
// first use and replaced when the operator picks another source.
srcMu sync.Mutex
cur *sourceConn
}
// New builds the server and everything it owns.
@@ -60,10 +68,6 @@ func New(cfg Config) (*Server, error) {
return nil, fmt.Errorf("create package directory: %w", err)
}
docker, err := dkr.New(cfg.DockerHost)
if err != nil {
return nil, err
}
conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
if err != nil {
return nil, err
@@ -72,17 +76,35 @@ func New(cfg Config) (*Server, error) {
if err != nil {
return nil, err
}
local := store.Source{Name: "this host", DockerHost: cfg.DockerHost}
if local.DockerHost == "" {
local.DockerHost = os.Getenv("DOCKER_HOST")
}
sources, err := store.NewSources(filepath.Join(cfg.DataDir, "sources.json"), local)
if err != nil {
return nil, err
}
// An explicit --docker-host is an instruction for this run, so it overrides
// the source remembered from the last one.
if cfg.DockerHostSet {
if err := sources.Select(store.LocalSourceID); err != nil {
return nil, err
}
}
s := &Server{
cfg: cfg, log: cfg.Logger, docker: docker,
cfg: cfg, log: cfg.Logger, sources: sources,
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
}
s.routes()
return s, nil
}
// Close releases the Docker connection.
func (s *Server) Close() error { return s.docker.Close() }
// Close releases the connection to the current source.
func (s *Server) Close() error {
s.invalidateSource("")
return nil
}
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
@@ -96,6 +118,13 @@ func (s *Server) routes() {
m.HandleFunc("GET /api/source", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
m.HandleFunc("GET /api/sources", s.handleListSources)
m.HandleFunc("POST /api/sources", s.handleSaveSource)
m.HandleFunc("DELETE /api/sources/{id}", s.handleDeleteSource)
m.HandleFunc("POST /api/sources/{id}/select", s.handleSelectSource)
m.HandleFunc("POST /api/sources/{id}/probe", s.handleSourceProbe)
m.HandleFunc("POST /api/sources/{id}/trust", s.handleSourceTrust)
m.HandleFunc("GET /api/connections", s.handleListConnections)
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
+328
View File
@@ -0,0 +1,328 @@
package api
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"time"
"github.com/arescom/dockmv/internal/dkr"
"github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/internal/store"
)
// sourceConn is a live connection to one source daemon.
//
// It is reference counted rather than closed eagerly: a migration holds its
// source for as long as it runs, so switching source in the UI half way through
// a transfer must not pull the socket out from under it. The connection is
// closed once it is both replaced and unused.
type sourceConn struct {
src store.Source
docker *dkr.Client
ssh *sshx.Client
version string
refs int
stale bool
}
func (c *sourceConn) close() {
if c.docker != nil {
_ = c.docker.Close()
}
if c.ssh != nil {
_ = c.ssh.Close()
}
}
// sourceStatus is what the UI shows about the source it is looking at.
type sourceStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Kind store.SourceKind `json:"kind"`
Endpoint string `json:"endpoint"`
DockerVersion string `json:"dockerVersion,omitempty"`
Connected bool `json:"connected"`
Error string `json:"error,omitempty"`
}
func (c *sourceConn) status() sourceStatus {
return sourceStatus{
ID: c.src.ID, Name: c.src.Name, Kind: c.src.Kind,
Endpoint: c.docker.Endpoint, DockerVersion: c.version, Connected: true,
}
}
// source returns the current source, connecting to it on first use. The
// returned release function must be called when the caller is done with it —
// for a job, when the job finishes.
func (s *Server) source(ctx context.Context) (*sourceConn, func(), error) {
s.srcMu.Lock()
if cur := s.cur; cur != nil {
cur.refs++
s.srcMu.Unlock()
return cur, func() { s.releaseSource(cur) }, nil
}
id := s.sources.Selected()
s.srcMu.Unlock()
conn, err := s.dialSource(ctx, id)
if err != nil {
return nil, nil, err
}
s.srcMu.Lock()
defer s.srcMu.Unlock()
// Another request may have connected while this one was dialling; one
// connection is enough, so the loser is dropped.
if s.cur != nil {
conn.close()
conn = s.cur
} else {
s.cur = conn
}
conn.refs++
return conn, func() { s.releaseSource(conn) }, nil
}
func (s *Server) releaseSource(c *sourceConn) {
s.srcMu.Lock()
defer s.srcMu.Unlock()
c.refs--
if c.refs <= 0 && c.stale {
c.close()
}
}
// selectSource connects to a source and, once that worked, makes it the current
// one and records the choice for the next run.
func (s *Server) selectSource(ctx context.Context, id string) (sourceStatus, error) {
conn, err := s.dialSource(ctx, id)
if err != nil {
return sourceStatus{}, err
}
st := conn.status()
s.srcMu.Lock()
old := s.cur
s.cur = conn
if old != nil {
old.stale = true
if old.refs <= 0 {
old.close()
}
}
s.srcMu.Unlock()
if err := s.sources.Select(conn.src.ID); err != nil {
return st, fmt.Errorf("remember the selected source: %w", err)
}
s.log.Info("source selected", "id", conn.src.ID, "endpoint", conn.docker.Endpoint)
return st, nil
}
// invalidateSource drops the cached connection when the source behind it has
// been edited or removed. An empty id invalidates whatever is current.
func (s *Server) invalidateSource(id string) {
s.srcMu.Lock()
defer s.srcMu.Unlock()
if s.cur == nil {
return
}
if id != "" && s.cur.src.ID != id {
return
}
s.cur.stale = true
if s.cur.refs <= 0 {
s.cur.close()
}
s.cur = nil
}
// dialSource opens a connection to one source and verifies the daemon answers.
func (s *Server) dialSource(ctx context.Context, id string) (*sourceConn, error) {
src, err := s.sources.Get(id)
if err != nil {
return nil, err
}
conn := &sourceConn{src: src}
switch src.Kind {
case store.SourceLocal, store.SourceDocker:
c, err := dkr.New(src.DockerHost)
if err != nil {
return nil, err
}
conn.docker = c
case store.SourceSSH:
if src.SSH == nil {
return nil, errors.New("source has no ssh configuration")
}
client, err := sshx.Dial(ctx, *src.SSH, s.hosts)
if err != nil {
return nil, err
}
rd := sshx.NewRemoteDocker(client)
// The CLI is checked first: a missing binary or a user outside the
// docker group is a readable error here, and an unexplained broken
// socket if it is left to the tunnel.
if _, err := rd.ProbeCLI(ctx); err != nil {
client.Close()
return nil, err
}
c, err := dkr.NewTunnel(sourceEndpoint(src), func(ctx context.Context, _, _ string) (net.Conn, error) {
return rd.DialAPI(ctx)
})
if err != nil {
client.Close()
return nil, err
}
conn.ssh, conn.docker = client, c
default:
return nil, fmt.Errorf("unknown source kind %q", src.Kind)
}
pingCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
version, err := conn.docker.Ping(pingCtx)
if err != nil {
conn.close()
return nil, fmt.Errorf("connect to docker at %s: %w", conn.docker.Endpoint, err)
}
conn.version = version
return conn, nil
}
// sourceEndpoint describes where a source lives, before and after it is
// connected to.
func sourceEndpoint(src store.Source) string {
if src.Kind == store.SourceSSH && src.SSH != nil {
port := src.SSH.Port
if port == 0 {
port = 22
}
return fmt.Sprintf("ssh://%s@%s", src.SSH.User, net.JoinHostPort(src.SSH.Host, strconv.Itoa(port)))
}
return src.DockerHost
}
// handleListSources lists the sources without connecting to any of them; the
// state of the current one comes from /api/health.
func (s *Server) handleListSources(w http.ResponseWriter, r *http.Request) {
s.srcMu.Lock()
var current *sourceStatus
if s.cur != nil {
st := s.cur.status()
current = &st
}
s.srcMu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{
"sources": s.sources.List(),
"selected": s.sources.Selected(),
"current": current,
})
}
func (s *Server) handleSaveSource(w http.ResponseWriter, r *http.Request) {
var src store.Source
if err := decode(r, &src); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
saved, err := s.sources.Save(src)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
// Editing the source in use means the live connection describes the old
// settings; drop it so the next call reconnects.
s.invalidateSource(saved.ID)
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleDeleteSource(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.sources.Delete(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "%v", err)
return
}
writeError(w, http.StatusBadRequest, "%v", err)
return
}
s.invalidateSource(id)
w.WriteHeader(http.StatusNoContent)
}
// handleSelectSource switches the source the whole UI works against.
func (s *Server) handleSelectSource(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
st, err := s.selectSource(ctx, r.PathValue("id"))
if err != nil {
s.writeDialError(w, err)
return
}
writeJSON(w, http.StatusOK, st)
}
// handleSourceProbe reads the SSH host key of a source host, so its fingerprint
// can be approved the same way a target's is.
func (s *Server) handleSourceProbe(w http.ResponseWriter, r *http.Request) {
cfg, err := s.sourceSSH(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
info, err := sshx.Probe(ctx, cfg, s.hosts)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, info)
}
func (s *Server) handleSourceTrust(w http.ResponseWriter, r *http.Request) {
cfg, err := s.sourceSSH(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
var body struct {
Fingerprint string `json:"fingerprint"`
}
if err := decode(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := sshx.TrustFromProbe(ctx, cfg, s.hosts, body.Fingerprint); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"trusted": true})
}
// sourceSSH returns the SSH configuration of a source, refusing the ones that
// are not reached over SSH.
func (s *Server) sourceSSH(id string) (sshx.Config, error) {
src, err := s.sources.Get(id)
if err != nil {
return sshx.Config{}, err
}
if src.Kind != store.SourceSSH || src.SSH == nil {
return sshx.Config{}, fmt.Errorf("source %s is not reached over ssh", src.Name)
}
return *src.SSH, nil
}
+40
View File
@@ -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 }
+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" />
<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>
+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)")
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)")
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")
fs.Usage = func() {
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 {
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
if *verbose {
@@ -118,6 +126,7 @@ func serve(args []string) error {
DataDir: *dataDir,
PackageDir: *pkgDir,
DockerHost: *dockerHost,
DockerHostSet: dockerHostSet,
UI: webui.FS(),
Logger: logger,
}
+57 -5
View File
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from './api'
import type {
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, SourceResponse, TargetInventory,
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, Source, SourceResponse,
SourceStatus, TargetInventory,
} from './types'
import { Notice } from './ui'
import { Containers } from './Containers'
import { SourcePanel } from './SourcePanel'
import { Sidebar } from './Sidebar'
import { Jobs } from './Jobs'
import { Packages } from './Packages'
@@ -22,6 +24,9 @@ export default function App() {
// Volume sizes come from a separate, slower endpoint so the container list
// can render immediately; they are merged into the rows when they arrive.
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 [activeConn, setActiveConn] = useState<string>('')
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 () => {
try {
const list = await api.connections()
@@ -80,12 +119,13 @@ export default function App() {
}, [])
useEffect(() => {
api.health().then(setHealth).catch(() => undefined)
loadHealth()
loadSources()
loadSource()
loadConnections()
loadJobs()
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
// every job; the detail view subscribes to its own live stream.
@@ -138,7 +178,9 @@ export default function App() {
<div className="topbar-right">
{health && (
<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}</>}
</span>
)}
@@ -160,8 +202,10 @@ export default function App() {
{health && !health.ok && (
<div style={{ padding: '10px 16px' }}>
<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}</>}
<div className="small">Pick another source in the panel on the right.</div>
</Notice>
</div>
)}
@@ -192,6 +236,14 @@ export default function App() {
{view === 'containers' && (
<aside className="sidebar">
<SourcePanel
sources={sources}
selected={selectedSource}
status={sourceStatus}
selectSource={selectSource}
reload={loadSources}
onError={setError}
/>
<Sidebar
source={source}
plan={plan}
+3 -103
View File
@@ -4,6 +4,7 @@ import type {
Connection, HostKeyInfo, JobSnapshot, Options, Plan, PreviewResponse, SourceResponse, TargetInventory,
} from './types'
import { Check, Field, humanBytes, Modal, Notice } from './ui'
import { HostKeyDialog, SshFields } from './SshFields'
export function Sidebar({
source, plan, includedCount, options, setOptions,
@@ -302,37 +303,7 @@ export function Sidebar({
)}
{hostKey && (
<Modal
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>
<HostKeyDialog info={hostKey} onClose={() => setHostKey(null)} onTrust={trustHostKey} />
)}
{preview && <PreviewModal data={preview} onClose={() => setPreview(null)} />}
@@ -382,79 +353,8 @@ function ConnectionDialog({
}
>
<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="host"><input type="text" value={c.host ?? ''} onChange={(e) => set('host', e.target.value)} /></Field>
<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>
)}
<SshFields value={c} set={set} where="target" />
</div>
</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 {
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
Preflight, PreviewResponse, SourceResponse, TargetInventory,
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory,
} from './types'
// 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'),
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'),
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
+30
View File
@@ -120,6 +120,35 @@ export interface SourceResponse {
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 interface Connection {
@@ -253,6 +282,7 @@ export interface Health {
dockerHost: string
dockerVersion?: string
dockerError?: string
source?: SourceStatus
packageDir: string
dataDir: string
knownHosts: string