Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s

- 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>
This commit is contained in:
2026-08-24 15:13:54 +02:00
co-authored by Claude Sonnet 5
parent 17a83e737f
commit 8d20d9be84
28 changed files with 1635 additions and 137 deletions
+14 -28
View File
@@ -9,8 +9,6 @@ package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
@@ -81,8 +79,7 @@ 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")
token := fs.String("token", os.Getenv("DOCKMV_TOKEN"), "require this token on every request; \"auto\" generates one (default: $DOCKMV_TOKEN)")
dataDir := fs.String("data-dir", defaultDataDir(), "directory for connections and trusted host keys")
dataDir := fs.String("data-dir", defaultDataDir(), "directory for 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")
@@ -108,21 +105,8 @@ func serve(args []string) error {
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
authToken := *token
if authToken == "auto" {
authToken = randomToken()
}
// Anything but a loopback bind is reachable by other machines. This tool
// can stop containers and read every volume on the host, so it refuses to
// be exposed without a token.
if authToken == "" && !isLoopback(*addr) {
authToken = randomToken()
logger.Warn("listening on a non-loopback address; generated an access token")
}
cfg := api.Config{
Addr: *addr,
Token: authToken,
DataDir: *dataDir,
PackageDir: *pkgDir,
DockerHost: *dockerHost,
@@ -136,6 +120,19 @@ func serve(args []string) error {
}
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")
}
@@ -154,9 +151,6 @@ func serve(args []string) error {
}
url := "http://" + displayAddr(ln.Addr().String())
if authToken != "" {
url += "/?token=" + authToken
}
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)
@@ -227,14 +221,6 @@ func defaultDataDir() string {
return ".dockmv"
}
func randomToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func isLoopback(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {