Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user