Files
DockMV/main.go
T
kawaandClaude Sonnet 5 8d20d9be84
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
Feature 1: Local accounts, replacing shared token
- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup
- Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access
- Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal)
- Login rate limiting (exponential backoff 1s–30s cap) per IP
- Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping)
- Every account can manage every other account (no roles in v1)
- Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check
- Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed
- Account tab for personal token management; sign-out button in topbar
- Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated

Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:13:54 +02:00

253 lines
7.0 KiB
Go

// Command dockmv moves Docker containers, together with their volumes
// and bind mounts, from one host to another.
//
// It runs either as a web application (the default) or as a one-shot inventory
// dump for scripting. It needs no agent on the target: everything is driven
// through the target's own docker CLI over SSH, or through a self-contained
// package that is carried to the target by hand.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/arescom/dockmv/internal/api"
"github.com/arescom/dockmv/internal/dkr"
"github.com/arescom/dockmv/internal/webui"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
if err := run(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
os.Exit(2)
}
fmt.Fprintln(os.Stderr, "error: "+err.Error())
os.Exit(1)
}
}
func run(args []string) error {
cmd := "serve"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
cmd, args = args[0], args[1:]
}
switch cmd {
case "serve":
return serve(args)
case "inspect":
return inspect(args)
case "version":
fmt.Printf("dockmv %s (%s %s/%s)\n", version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
return nil
case "help", "-h", "--help":
usage()
return nil
default:
usage()
return fmt.Errorf("unknown command %q", cmd)
}
}
func usage() {
fmt.Fprint(os.Stderr, `dockmv - move Docker containers and their data between hosts
Usage:
dockmv [serve] [flags] start the web interface (default)
dockmv inspect [flags] print the source inventory as JSON
dockmv version print the version
Run "dockmv serve -h" for the server flags.
`)
}
func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on; use 0.0.0.0:8080 to expose it on the network")
dataDir := fs.String("data-dir", defaultDataDir(), "directory for accounts, connections and trusted host keys")
pkgDir := fs.String("package-dir", "", "directory for migration packages (default <data-dir>/packages)")
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:")
fs.PrintDefaults()
}
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 {
level = slog.LevelDebug
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
cfg := api.Config{
Addr: *addr,
DataDir: *dataDir,
PackageDir: *pkgDir,
DockerHost: *dockerHost,
DockerHostSet: dockerHostSet,
UI: webui.FS(),
Logger: logger,
}
srv, err := api.New(cfg)
if err != nil {
return err
}
defer srv.Close()
// This tool can stop containers and read every volume on the host, so a
// fresh install must complete account setup from loopback before it is
// reachable from anywhere else. DOCKMV_TRUST_ADDR skips this when some
// other layer already restricts exposure — e.g. the Docker image, which
// binds 0.0.0.0 internally so `ports: "127.0.0.1:8080:8080"` can do the
// actual restricting.
if srv.NeedsSetup() && !isLoopback(*addr) && os.Getenv("DOCKMV_TRUST_ADDR") == "" {
return fmt.Errorf("no account exists yet; bind to a loopback address first "+
"(e.g. --addr 127.0.0.1:8080), reach it with an SSH tunnel if needed "+
"(ssh -L 8080:127.0.0.1:8080 user@this-host), finish setup, then restart with %s; "+
"or set DOCKMV_TRUST_ADDR=1 if exposure is already restricted elsewhere (e.g. a container's own port mapping)", *addr)
}
if !webui.Built() {
logger.Warn("web UI is not embedded in this binary; only the HTTP API is available")
}
httpSrv := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 15 * time.Second,
// Migrations stream for as long as the data takes; no write timeout.
IdleTimeout: 120 * time.Second,
}
ln, err := net.Listen("tcp", *addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", *addr, err)
}
url := "http://" + displayAddr(ln.Addr().String())
fmt.Fprintf(os.Stderr, "\n dockmv %s\n open %s\n data: %s\n\n", version, url, *dataDir)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
if err := httpSrv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
}
}
func inspect(args []string) error {
fs := flag.NewFlagSet("inspect", flag.ContinueOnError)
dockerHost := fs.String("docker-host", "", "docker daemon to read (default: the DOCKER_HOST environment)")
sizes := fs.Bool("sizes", false, "also measure volume sizes (slower)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: dockmv inspect [flags]\n\nFlags:")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
c, err := dkr.New(*dockerHost)
if err != nil {
return err
}
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
inv, err := c.Inventory(ctx)
if err != nil {
return err
}
if *sizes {
if err := c.MeasureMounts(ctx, inv.Containers); err != nil {
fmt.Fprintln(os.Stderr, "warning: could not measure volume sizes: "+err.Error())
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(inv)
}
// defaultDataDir picks a per-user location, honouring the container-friendly
// DOCKMV_DATA override.
func defaultDataDir() string {
if v := os.Getenv("DOCKMV_DATA"); v != "" {
return v
}
if dir, err := os.UserConfigDir(); err == nil {
return filepath.Join(dir, "dockmv")
}
return ".dockmv"
}
func isLoopback(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if host == "" {
return false // an empty host binds every interface
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// displayAddr turns a wildcard bind into something clickable.
func displayAddr(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
if host == "" || host == "0.0.0.0" || host == "::" {
return "localhost:" + port
}
if strings.Contains(host, ":") {
return "[" + host + "]:" + port
}
return host + ":" + port
}