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
+3
View File
@@ -12,6 +12,9 @@ web/node_modules/
*.log
.DS_Store
# Local planning notes
/PLAN.md
# NOTE: internal/webui/dist IS committed on purpose. It is the embedded web UI,
# and keeping it in the tree means `go build` alone produces a working binary
# without a Node toolchain. Regenerate it with `make ui`.
+28 -8
View File
@@ -31,17 +31,17 @@ reach it — see [sources](#sources).
```bash
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
docker compose up -d
docker compose logs dockmv # prints the URL, including the access token
docker compose logs dockmv # prints the URL
```
Open the printed URL. It binds to `127.0.0.1` only; reach it from your laptop with a tunnel:
Open the printed URL, create the first account (there is no default login), and you're in. 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
```
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.
Uses the published image `git.azuze.fr/kawa/dockmv:latest`. Pin a version with `VERSION=v1.2.0 docker compose up -d`.
### Prebuilt binary
@@ -190,7 +190,12 @@ carries only genuine run-time overrides and keeps working when the image is upda
The tool can stop containers and read every volume on the host, so it is treated as a privileged
admin tool:
- Binds to **`127.0.0.1` by default**. Binding elsewhere auto-generates an access token and prints it.
- Binds to **`127.0.0.1` by default**. A fresh install has no account yet, and refuses to bind
anywhere else until one is created — finish setup over an SSH tunnel first (see above).
- **Local accounts, no roles yet.** The first visit creates an account; there is no default login.
Every account can manage every other account and every resource — the same single-workspace model
the rest of the tool already has. Scripted access uses a **personal API token** (Account tab),
sent as `X-Auth-Token`, instead of the old shared `--token`.
- **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`.
@@ -255,23 +260,38 @@ 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 sources, connections and trusted host keys (default: OS config dir)
--data-dir string accounts, 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 local source docker daemon (default: the DOCKER_HOST environment);
given explicitly, it overrides the remembered source
-v verbose logging
```
Binding to anything but a loopback address refuses to start until an account exists — see the Safety
section above. Set `DOCKMV_TRUST_ADDR=1` to skip that check when something else already restricts
exposure, e.g. a container's own port mapping (the shipped `docker-compose.yml` does this).
`inspect` is handy for scripting and for reporting bugs:
```bash
dockmv inspect --sizes | jq '.containers[] | {name, image, mounts}'
```
Everything the UI does is available over HTTP. Pass the token as `X-Auth-Token` when one is set.
Everything the UI does is available over HTTP. The browser uses a session cookie, set by
`/api/login`; scripts use a personal API token from the Account tab, passed as `X-Auth-Token` or
`Authorization: Bearer …`.
```
POST /api/setup create the first account (only while none exists)
POST /api/login
POST /api/logout
GET /api/me who's signed in, or whether setup is still needed
GET /api/users every account
POST /api/users create another account
DELETE /api/users/{id}
GET /api/tokens the caller's own personal API tokens
POST /api/tokens create one; the plaintext is only ever shown once
DELETE /api/tokens/{id}
GET /api/health
GET /api/source inventory + default selections
GET /api/source/sizes volume sizes (slow)
+9 -5
View File
@@ -1,11 +1,12 @@
# dockmv, running on the SOURCE host.
#
# docker compose up -d
# docker compose logs dockmv # the URL and access token are printed here
# docker compose logs dockmv # the URL is printed here
#
# The container needs the Docker socket to read containers and stream their
# data. That is equivalent to root on this host, so the UI is protected by a
# generated token and should not be published to an untrusted network.
# login (created on first visit) and should not be published to an untrusted
# network.
services:
dockmv:
@@ -18,12 +19,15 @@ services:
- "127.0.0.1:8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Connections, trusted SSH host keys and built packages.
# Accounts, connections, trusted SSH host keys and built packages.
- migrate-data:/data
environment:
# Set a fixed token to keep the same URL across restarts.
# DOCKMV_TOKEN: change-me
TZ: ${TZ:-UTC}
# dockmv listens on 0.0.0.0 *inside* the container so the ports: mapping
# above can reach it; that mapping (127.0.0.1 on the host side) is the
# actual loopback restriction, not this bind address. This tells dockmv
# not to second-guess that and refuse to start before setup is done.
DOCKMV_TRUST_ADDR: "1"
command:
- serve
- --addr=0.0.0.0:8080
+227
View File
@@ -0,0 +1,227 @@
package api
import (
"context"
"net"
"net/http"
"strings"
"github.com/arescom/dockmv/internal/session"
"github.com/arescom/dockmv/internal/store"
)
// Identity is the authenticated caller, attached to the request context by
// auth(). Every handler reached past auth() has one.
type Identity struct {
UserID string
Username string
}
type identityKey struct{}
func identityFrom(r *http.Request) Identity {
id, _ := r.Context().Value(identityKey{}).(Identity)
return id
}
// publicPaths need no authentication: they are how a fresh install creates
// its first account, how a session is established or torn down, and the
// health check the UI's own bootstrap (and Docker's HEALTHCHECK) depend on.
var publicPaths = map[string]bool{
"/api/setup": true,
"/api/login": true,
"/api/logout": true,
"/api/me": true,
"/api/health": true,
}
// auth gates every /api/* request behind a session cookie or a personal API
// token.
//
// Static assets are served without it: see the comment on spaHandler. Nothing
// sensitive lives in the bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") || publicPaths[r.URL.Path] {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie(session.CookieName); err == nil {
if sess, ok := s.sessions.Get(c.Value); ok {
s.serveAs(w, r, next, Identity{UserID: sess.UserID, Username: sess.Username})
return
}
}
if got := bearerToken(r); got != "" {
if tok, err := s.tokens.Authenticate(got); err == nil {
if usr, err := s.users.Get(tok.UserID); err == nil {
s.serveAs(w, r, next, Identity{UserID: usr.ID, Username: usr.Username})
return
}
}
}
writeError(w, http.StatusUnauthorized, "authentication required")
})
}
func (s *Server) serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, id Identity) {
ctx := context.WithValue(r.Context(), identityKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
}
func bearerToken(r *http.Request) string {
if got := r.Header.Get("X-Auth-Token"); got != "" {
return got
}
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
// isHTTPS reports whether the request reached us over TLS, directly or
// through a reverse proxy that sets the standard forwarded-proto header. It
// decides the session cookie's Secure attribute.
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, sess session.Session) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
Expires: sess.ExpiresAt,
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// remoteIP strips the port from RemoteAddr, for the login rate limiter.
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
type setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// handleSetup creates the first account. It only succeeds while no account
// exists yet; main.go also refuses to bind non-loopback until then, so this
// endpoint being open to anyone is not a standing risk.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.NeedsSetup() {
writeError(w, http.StatusConflict, "setup has already been completed")
return
}
var req setupRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Create(req.Username, req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
s.completeLogin(w, r, usr)
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := remoteIP(r)
if !s.logins.Allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many attempts; try again shortly")
return
}
var req loginRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Verify(req.Username, req.Password)
if err != nil {
s.logins.Fail(ip)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
s.logins.Reset(ip)
_ = s.users.TouchLastLogin(usr.ID)
s.completeLogin(w, r, usr)
}
func (s *Server) completeLogin(w http.ResponseWriter, r *http.Request, usr store.User) {
sess, err := s.sessions.Create(usr.ID, usr.Username)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
setSessionCookie(w, r, *sess)
writeJSON(w, http.StatusOK, meResponse{ID: usr.ID, Username: usr.Username, Authenticated: true})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(session.CookieName); err == nil {
s.sessions.Delete(c.Value)
}
clearSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}
// meResponse is also what /api/setup and /api/login return on success.
type meResponse struct {
ID string `json:"id,omitempty"`
Username string `json:"username,omitempty"`
Authenticated bool `json:"authenticated"`
NeedsSetup bool `json:"needsSetup,omitempty"`
}
// handleMe reports the caller's identity, or that setup is still needed. It
// is public so the UI can decide, on load, whether to show the setup screen,
// a login form, or the app itself.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if s.NeedsSetup() {
writeJSON(w, http.StatusOK, meResponse{NeedsSetup: true})
return
}
if c, err := r.Cookie(session.CookieName); err == nil {
if sess, ok := s.sessions.Get(c.Value); ok {
writeJSON(w, http.StatusOK, meResponse{ID: sess.UserID, Username: sess.Username, Authenticated: true})
return
}
}
if got := bearerToken(r); got != "" {
if tok, err := s.tokens.Authenticate(got); err == nil {
if usr, err := s.users.Get(tok.UserID); err == nil {
writeJSON(w, http.StatusOK, meResponse{ID: usr.ID, Username: usr.Username, Authenticated: true})
return
}
}
}
writeJSON(w, http.StatusOK, meResponse{})
}
-1
View File
@@ -29,7 +29,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
"authRequired": s.cfg.Token != "",
}
// Health is also what connects to the selected source on a fresh start, so
+39 -40
View File
@@ -2,7 +2,6 @@
package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io/fs"
@@ -15,6 +14,7 @@ import (
"time"
"github.com/arescom/dockmv/internal/job"
"github.com/arescom/dockmv/internal/session"
"github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/internal/store"
)
@@ -23,9 +23,7 @@ import (
type Config struct {
// Addr is the listen address, e.g. 127.0.0.1:8080.
Addr string
// Token, when set, must be presented on every API request.
Token string
// DataDir holds connections and the known-hosts file.
// DataDir holds accounts, connections and the known-hosts file.
DataDir string
// PackageDir is where migration packages are written.
PackageDir string
@@ -48,6 +46,10 @@ type Server struct {
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
users *store.Users
tokens *store.Tokens
sessions *session.Manager
logins *session.Limiter
mux *http.ServeMux
// srcMu guards cur, the connection to the selected source. It is opened on
@@ -92,14 +94,33 @@ func New(cfg Config) (*Server, error) {
}
}
users, err := store.NewUsers(filepath.Join(cfg.DataDir, "users.json"))
if err != nil {
return nil, err
}
tokens, err := store.NewTokens(filepath.Join(cfg.DataDir, "tokens.json"))
if err != nil {
return nil, err
}
s := &Server{
cfg: cfg, log: cfg.Logger, sources: sources,
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
conns: conns, hosts: hosts, jobs: job.NewManager(),
users: users, tokens: tokens,
sessions: session.NewManager(), logins: session.NewLimiter(),
mux: http.NewServeMux(),
}
s.routes()
return s, nil
}
// NeedsSetup reports whether no account exists yet. main.go refuses to bind
// non-loopback while this is true, and GET /api/me uses it to decide whether
// the UI should show the first-run setup screen instead of a login form.
func (s *Server) NeedsSetup() bool {
return s.users.Count() == 0
}
// Close releases the connection to the current source.
func (s *Server) Close() error {
s.invalidateSource("")
@@ -114,6 +135,19 @@ func (s *Server) Handler() http.Handler {
func (s *Server) routes() {
m := s.mux
m.HandleFunc("POST /api/setup", s.handleSetup)
m.HandleFunc("POST /api/login", s.handleLogin)
m.HandleFunc("POST /api/logout", s.handleLogout)
m.HandleFunc("GET /api/me", s.handleMe)
m.HandleFunc("GET /api/users", s.handleListUsers)
m.HandleFunc("POST /api/users", s.handleCreateUser)
m.HandleFunc("DELETE /api/users/{id}", s.handleDeleteUser)
m.HandleFunc("GET /api/tokens", s.handleListTokens)
m.HandleFunc("POST /api/tokens", s.handleCreateToken)
m.HandleFunc("DELETE /api/tokens/{id}", s.handleRevokeToken)
m.HandleFunc("GET /api/health", s.handleHealth)
m.HandleFunc("GET /api/source", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
@@ -152,41 +186,6 @@ func (s *Server) routes() {
}
}
// auth enforces the shared token on the API. The token may also be passed as a
// query parameter, because EventSource cannot set headers and neither can a
// download link.
//
// Static assets are deliberately served without it. A browser opening
// /?token=… does not carry the query string over to /assets/app.js, so gating
// the shell would leave the UI unable to boot. Nothing sensitive lives in the
// bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
if s.cfg.Token == "" {
return next
}
want := []byte(s.cfg.Token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-Auth-Token")
if got == "" {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got = strings.TrimPrefix(h, "Bearer ")
}
}
if got == "" {
got = r.URL.Query().Get("token")
}
if subtle.ConstantTimeCompare([]byte(got), want) != 1 {
writeError(w, http.StatusUnauthorized, "invalid or missing token")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"net/http"
"github.com/arescom/dockmv/internal/store"
)
// handleListTokens lists only the caller's own tokens; there is no way to see
// another account's.
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.tokens.List(identityFrom(r).UserID))
}
type createTokenRequest struct {
Name string `json:"name"`
}
// createTokenResponse embeds the stored token plus the plaintext secret,
// which is only ever shown this once.
type createTokenResponse struct {
store.APIToken
Token string `json:"token"`
}
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
var req createTokenRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
tok, plain, err := s.tokens.Create(identityFrom(r).UserID, req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
writeJSON(w, http.StatusCreated, createTokenResponse{APIToken: tok, Token: plain})
}
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
if err := s.tokens.Revoke(identityFrom(r).UserID, r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+49
View File
@@ -0,0 +1,49 @@
package api
import (
"net/http"
"github.com/arescom/dockmv/internal/store"
)
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.users.List())
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// handleCreateUser adds another account. v1 has no roles: any authenticated
// account can create or remove any other.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Create(req.Username, req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusCreated, usr)
}
// handleDeleteUser removes an account, along with its sessions and personal
// API tokens so nothing left behind still authenticates as it.
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.users.Delete(id); err != nil {
status := http.StatusNotFound
if err == store.ErrLastAccount {
status = http.StatusBadRequest
}
writeError(w, status, "%v", err)
return
}
s.sessions.DeleteAllForUser(id)
_ = s.tokens.RevokeAllForUser(id)
w.WriteHeader(http.StatusNoContent)
}
+1
View File
@@ -39,6 +39,7 @@ const (
KindSSH Kind = "ssh"
KindPackage Kind = "package"
KindRestore Kind = "restore"
KindBackup Kind = "backup"
)
// Level classifies a log line.
+76
View File
@@ -0,0 +1,76 @@
package session
import (
"sync"
"time"
)
// limiterBase and limiterCap bound the exponential backoff: 1s, 2s, 4s, ...
// capped at 30s.
const (
limiterBase = 1 * time.Second
limiterCap = 30 * time.Second
// maxShift keeps 1<<n from overflowing and is already well past the point
// backoff hits limiterCap, so failures beyond it just stay capped.
maxShift = 5
)
// Limiter throttles login attempts per key (typically the remote IP) with
// exponential backoff, so password guessing is slow without locking anyone
// out for long. It never expires idle keys on its own; a v1-scale accounts
// file does not make that worth the complexity.
type Limiter struct {
mu sync.Mutex
state map[string]*limiterState
}
type limiterState struct {
failures int
blockedUntil time.Time
}
// NewLimiter returns an empty limiter.
func NewLimiter() *Limiter {
return &Limiter{state: map[string]*limiterState{}}
}
// Allow reports whether key may attempt a login right now.
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.state[key]
if !ok {
return true
}
return !time.Now().Before(st.blockedUntil)
}
// Fail records a failed attempt for key and returns the backoff before the
// next one is allowed.
func (l *Limiter) Fail(key string) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.state[key]
if !ok {
st = &limiterState{}
l.state[key] = st
}
st.failures++
shift := st.failures - 1
if shift > maxShift {
shift = maxShift
}
backoff := limiterBase * time.Duration(1<<shift)
if backoff > limiterCap {
backoff = limiterCap
}
st.blockedUntil = time.Now().Add(backoff)
return backoff
}
// Reset clears key's failure history after a successful login.
func (l *Limiter) Reset(key string) {
l.mu.Lock()
delete(l.state, key)
l.mu.Unlock()
}
+95
View File
@@ -0,0 +1,95 @@
// Package session tracks logged-in browser sessions in memory. There is no
// persistence and no signing: state lives only for the life of the process,
// so a restart already invalidates every session, and signing would protect
// nothing that a restart doesn't already.
package session
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// CookieName is the session cookie set on a successful login.
const CookieName = "dockmv_session"
// TTL is how long a session survives without activity. Every successful
// lookup slides the expiry forward by this much.
const TTL = 7 * 24 * time.Hour
// Session is one logged-in browser tab's worth of state.
type Session struct {
ID string
UserID string
Username string
ExpiresAt time.Time
}
// Manager holds every live session.
type Manager struct {
mu sync.Mutex
byID map[string]*Session
}
// NewManager returns an empty session store.
func NewManager() *Manager {
return &Manager{byID: map[string]*Session{}}
}
// Create starts a new session for a logged-in account.
func (m *Manager) Create(userID, username string) (*Session, error) {
id, err := newSessionID()
if err != nil {
return nil, err
}
s := &Session{ID: id, UserID: userID, Username: username, ExpiresAt: time.Now().Add(TTL)}
m.mu.Lock()
m.byID[id] = s
m.mu.Unlock()
return s, nil
}
// Get returns the session for id, sliding its expiry forward. ok is false for
// an unknown or expired id.
func (m *Manager) Get(id string) (Session, bool) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.byID[id]
if !ok {
return Session{}, false
}
if time.Now().After(s.ExpiresAt) {
delete(m.byID, id)
return Session{}, false
}
s.ExpiresAt = time.Now().Add(TTL)
return *s, true
}
// Delete ends one session, e.g. on logout.
func (m *Manager) Delete(id string) {
m.mu.Lock()
delete(m.byID, id)
m.mu.Unlock()
}
// DeleteAllForUser ends every session belonging to an account, e.g. when the
// account itself is deleted.
func (m *Manager) DeleteAllForUser(userID string) {
m.mu.Lock()
for id, s := range m.byID {
if s.UserID == userID {
delete(m.byID, id)
}
}
m.mu.Unlock()
}
func newSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+197
View File
@@ -0,0 +1,197 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// ErrTokenNotFound is returned for an unknown, revoked, or not-owned token.
var ErrTokenNotFound = errors.New("token not found")
// APIToken is a personal access token, scoped to the account that created it.
// The plaintext is only ever returned once, by Create.
type APIToken struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
Hint string `json:"hint"` // last 4 characters, for telling tokens apart in a list
Hash string `json:"hash"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// Tokens is a JSON-backed collection of personal API tokens.
type Tokens struct {
path string
mu sync.RWMutex
// items holds every token by id.
items map[string]APIToken
// byHash maps a token's sha256 hex digest to its id, for authentication
// lookups without ever storing the plaintext.
byHash map[string]string
}
// NewTokens loads (or creates) the tokens file at path.
func NewTokens(path string) (*Tokens, error) {
t := &Tokens{path: path, items: map[string]APIToken{}, byHash: map[string]string{}}
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 t, nil
}
if err != nil {
return nil, fmt.Errorf("read tokens: %w", err)
}
var list []APIToken
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse tokens file %s: %w", path, err)
}
for _, tok := range list {
t.items[tok.ID] = tok
t.byHash[tok.Hash] = tok.ID
}
return t, nil
}
// List returns every token owned by userID, hash stripped, newest first.
func (t *Tokens) List(userID string) []APIToken {
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]APIToken, 0, len(t.items))
for _, tok := range t.items {
if tok.UserID == userID {
out = append(out, redactToken(tok))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// Create mints a new token for userID and returns it (hash stripped) plus the
// plaintext secret, which is never stored and never retrievable again.
func (t *Tokens) Create(userID, name string) (APIToken, string, error) {
plain, err := newTokenSecret()
if err != nil {
return APIToken{}, "", fmt.Errorf("generate token: %w", err)
}
hash := hashToken(plain)
t.mu.Lock()
defer t.mu.Unlock()
tok := APIToken{
ID: newID(),
UserID: userID,
Name: name,
Hint: plain[len(plain)-4:],
Hash: hash,
CreatedAt: time.Now(),
}
t.items[tok.ID] = tok
t.byHash[hash] = tok.ID
if err := t.flush(); err != nil {
return APIToken{}, "", err
}
return redactToken(tok), plain, nil
}
// Authenticate looks up the token behind a plaintext secret and records it as
// used. It does not check ownership: any valid token authenticates as its
// owner, which the caller then treats as the request's identity.
func (t *Tokens) Authenticate(plain string) (APIToken, error) {
hash := hashToken(plain)
t.mu.Lock()
defer t.mu.Unlock()
id, ok := t.byHash[hash]
if !ok {
return APIToken{}, ErrTokenNotFound
}
tok := t.items[id]
now := time.Now()
tok.LastUsedAt = &now
t.items[id] = tok
if err := t.flush(); err != nil {
return APIToken{}, err
}
return redactToken(tok), nil
}
// Revoke deletes a token, refusing if it is not owned by userID.
func (t *Tokens) Revoke(userID, id string) error {
t.mu.Lock()
defer t.mu.Unlock()
tok, ok := t.items[id]
if !ok || tok.UserID != userID {
return ErrTokenNotFound
}
delete(t.items, id)
delete(t.byHash, tok.Hash)
return t.flush()
}
// RevokeAllForUser deletes every token owned by userID, e.g. when the account
// itself is deleted.
func (t *Tokens) RevokeAllForUser(userID string) error {
t.mu.Lock()
defer t.mu.Unlock()
for id, tok := range t.items {
if tok.UserID == userID {
delete(t.items, id)
delete(t.byHash, tok.Hash)
}
}
return t.flush()
}
// flush writes the file. The caller must hold the write lock.
func (t *Tokens) flush() error {
list := make([]APIToken, 0, len(t.items))
for _, tok := range t.items {
list = append(list, tok)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := t.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write tokens: %w", err)
}
if err := os.Rename(tmp, t.path); err != nil {
return fmt.Errorf("replace tokens file: %w", err)
}
return nil
}
func redactToken(tok APIToken) APIToken {
tok.Hash = ""
return tok
}
// newTokenSecret generates a token in the form dmv_<48 hex characters>.
func newTokenSecret() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "dmv_" + hex.EncodeToString(b), nil
}
func hashToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
+145
View File
@@ -0,0 +1,145 @@
package store
import (
"path/filepath"
"strings"
"testing"
)
func newTestTokens(t *testing.T) (*Tokens, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "tokens.json")
tk, err := NewTokens(path)
if err != nil {
t.Fatalf("NewTokens: %v", err)
}
return tk, path
}
func TestTokensCreateAndAuthenticate(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "ci token")
if err != nil {
t.Fatalf("Create: %v", err)
}
if tok.Hash != "" {
t.Fatal("Create must return a redacted token")
}
if !strings.HasPrefix(plain, "dmv_") {
t.Fatalf("plaintext = %q, want dmv_ prefix", plain)
}
if tok.Hint != plain[len(plain)-4:] {
t.Fatalf("hint = %q, want last 4 chars of %q", tok.Hint, plain)
}
got, err := tk.Authenticate(plain)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if got.UserID != "user-1" {
t.Fatalf("UserID = %q, want user-1", got.UserID)
}
if got.Hash != "" {
t.Fatal("Authenticate must return a redacted token")
}
if _, err := tk.Authenticate("dmv_deadbeef"); err != ErrTokenNotFound {
t.Fatalf("Authenticate with bogus token = %v, want ErrTokenNotFound", err)
}
}
func TestTokensAuthenticateRecordsLastUsed(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "ci token")
if err != nil {
t.Fatalf("Create: %v", err)
}
if tok.LastUsedAt != nil {
t.Fatal("a fresh token should have no last-used time")
}
got, err := tk.Authenticate(plain)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if got.LastUsedAt == nil {
t.Fatal("Authenticate should record LastUsedAt")
}
}
func TestTokensListScopedToOwner(t *testing.T) {
tk, _ := newTestTokens(t)
if _, _, err := tk.Create("user-1", "a"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-2", "b"); err != nil {
t.Fatalf("Create: %v", err)
}
list := tk.List("user-1")
if len(list) != 1 || list[0].Name != "a" {
t.Fatalf("List(user-1) = %+v, want just token a", list)
}
}
func TestTokensRevokeOwnershipChecked(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "a")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := tk.Revoke("user-2", tok.ID); err != ErrTokenNotFound {
t.Fatalf("Revoke by non-owner = %v, want ErrTokenNotFound", err)
}
if err := tk.Revoke("user-1", tok.ID); err != nil {
t.Fatalf("Revoke by owner: %v", err)
}
if _, err := tk.Authenticate(plain); err != ErrTokenNotFound {
t.Fatalf("Authenticate after revoke = %v, want ErrTokenNotFound", err)
}
}
func TestTokensRevokeAllForUser(t *testing.T) {
tk, _ := newTestTokens(t)
if _, _, err := tk.Create("user-1", "a"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-1", "b"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-2", "c"); err != nil {
t.Fatalf("Create: %v", err)
}
if err := tk.RevokeAllForUser("user-1"); err != nil {
t.Fatalf("RevokeAllForUser: %v", err)
}
if len(tk.List("user-1")) != 0 {
t.Fatal("user-1 should have no tokens left")
}
if len(tk.List("user-2")) != 1 {
t.Fatal("user-2's token should be untouched")
}
}
func TestTokensPersistAcrossReload(t *testing.T) {
tk, path := newTestTokens(t)
_, plain, err := tk.Create("user-1", "a")
if err != nil {
t.Fatalf("Create: %v", err)
}
reopened, err := NewTokens(path)
if err != nil {
t.Fatalf("NewTokens: %v", err)
}
if _, err := reopened.Authenticate(plain); err != nil {
t.Fatalf("Authenticate after reload: %v", err)
}
}
+231
View File
@@ -0,0 +1,231 @@
package store
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// ErrUsernameTaken is returned by Create when the username is already in use.
var ErrUsernameTaken = errors.New("username already taken")
// ErrInvalidCredentials is returned by Verify on an unknown username or a
// wrong password. The two cases are not distinguished, to avoid leaking which
// usernames exist.
var ErrInvalidCredentials = errors.New("invalid username or password")
// ErrLastAccount is returned by Delete when it would remove the only account.
var ErrLastAccount = errors.New("cannot delete the last account")
// ErrPasswordTooLong is returned when a password exceeds bcrypt's 72-byte
// limit. Passwords are rejected rather than silently truncated.
var ErrPasswordTooLong = errors.New("password must be 72 bytes or fewer")
// User is one local account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"passwordHash"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
}
// Users is a JSON-backed collection of local accounts.
type Users struct {
path string
mu sync.RWMutex
// items holds every account by id.
items map[string]User
// byName maps a lowercased username to its account id, for case-insensitive
// uniqueness checks and login lookups.
byName map[string]string
}
// dummyHash is compared against on a lookup-miss in Verify, so a login attempt
// against a username that doesn't exist takes the same time as one that does.
var dummyHash = mustHash("dockmv-dummy-password-for-timing-only")
func mustHash(password string) []byte {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return h
}
// NewUsers loads (or creates) the users file at path.
func NewUsers(path string) (*Users, error) {
u := &Users{path: path, items: map[string]User{}, byName: map[string]string{}}
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 u, nil
}
if err != nil {
return nil, fmt.Errorf("read users: %w", err)
}
var list []User
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse users file %s: %w", path, err)
}
for _, usr := range list {
u.items[usr.ID] = usr
u.byName[strings.ToLower(usr.Username)] = usr.ID
}
return u, nil
}
// Count returns the number of accounts.
func (u *Users) Count() int {
u.mu.RLock()
defer u.mu.RUnlock()
return len(u.items)
}
// List returns every account, password hashes stripped, username order.
func (u *Users) List() []User {
u.mu.RLock()
defer u.mu.RUnlock()
out := make([]User, 0, len(u.items))
for _, usr := range u.items {
out = append(out, redactUser(usr))
}
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out
}
// Get returns one account, password hash stripped.
func (u *Users) Get(id string) (User, error) {
u.mu.RLock()
defer u.mu.RUnlock()
usr, ok := u.items[id]
if !ok {
return User{}, ErrNotFound
}
return redactUser(usr), nil
}
// Create adds a new account. Usernames are unique case-insensitively.
func (u *Users) Create(username, password string) (User, error) {
username = strings.TrimSpace(username)
if username == "" {
return User{}, errors.New("username is required")
}
if password == "" {
return User{}, errors.New("password is required")
}
if len(password) > 72 {
return User{}, ErrPasswordTooLong
}
u.mu.Lock()
defer u.mu.Unlock()
key := strings.ToLower(username)
if _, ok := u.byName[key]; ok {
return User{}, ErrUsernameTaken
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return User{}, fmt.Errorf("hash password: %w", err)
}
usr := User{ID: newID(), Username: username, PasswordHash: string(hash), CreatedAt: time.Now()}
u.items[usr.ID] = usr
u.byName[key] = usr.ID
if err := u.flush(); err != nil {
return User{}, err
}
return redactUser(usr), nil
}
// Verify checks a username/password pair and returns the account on success.
// A lookup-miss still runs a bcrypt comparison against a dummy hash, so the
// two failure cases take the same time.
func (u *Users) Verify(username, password string) (User, error) {
u.mu.RLock()
id, ok := u.byName[strings.ToLower(strings.TrimSpace(username))]
var usr User
if ok {
usr = u.items[id]
}
u.mu.RUnlock()
if !ok {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return User{}, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(usr.PasswordHash), []byte(password)); err != nil {
return User{}, ErrInvalidCredentials
}
return redactUser(usr), nil
}
// TouchLastLogin records the current time as an account's last login.
func (u *Users) TouchLastLogin(id string) error {
u.mu.Lock()
defer u.mu.Unlock()
usr, ok := u.items[id]
if !ok {
return ErrNotFound
}
now := time.Now()
usr.LastLoginAt = &now
u.items[id] = usr
return u.flush()
}
// Delete removes an account. The last remaining account cannot be deleted, so
// the instance never ends up with no way to log in.
func (u *Users) Delete(id string) error {
u.mu.Lock()
defer u.mu.Unlock()
usr, ok := u.items[id]
if !ok {
return ErrNotFound
}
if len(u.items) == 1 {
return ErrLastAccount
}
delete(u.items, id)
delete(u.byName, strings.ToLower(usr.Username))
return u.flush()
}
// flush writes the file. The caller must hold the write lock.
func (u *Users) flush() error {
list := make([]User, 0, len(u.items))
for _, usr := range u.items {
list = append(list, usr)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := u.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write users: %w", err)
}
if err := os.Rename(tmp, u.path); err != nil {
return fmt.Errorf("replace users file: %w", err)
}
return nil
}
func redactUser(usr User) User {
usr.PasswordHash = ""
return usr
}
+131
View File
@@ -0,0 +1,131 @@
package store
import (
"path/filepath"
"strings"
"testing"
)
func newTestUsers(t *testing.T) (*Users, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "users.json")
u, err := NewUsers(path)
if err != nil {
t.Fatalf("NewUsers: %v", err)
}
return u, path
}
func TestUsersCreateAndVerify(t *testing.T) {
u, _ := newTestUsers(t)
usr, err := u.Create("Alice", "correct-password")
if err != nil {
t.Fatalf("Create: %v", err)
}
if usr.PasswordHash != "" {
t.Fatal("Create must return a redacted user")
}
if usr.ID == "" {
t.Fatal("an id should have been generated")
}
if _, err := u.Verify("alice", "correct-password"); err != nil {
t.Fatalf("Verify with matching case-insensitive username: %v", err)
}
if _, err := u.Verify("Alice", "wrong-password"); err != ErrInvalidCredentials {
t.Fatalf("Verify with wrong password = %v, want ErrInvalidCredentials", err)
}
if _, err := u.Verify("nobody", "correct-password"); err != ErrInvalidCredentials {
t.Fatalf("Verify with unknown username = %v, want ErrInvalidCredentials", err)
}
}
func TestUsersUsernameUniqueCaseInsensitive(t *testing.T) {
u, _ := newTestUsers(t)
if _, err := u.Create("Bob", "password1"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := u.Create("bob", "password2"); err != ErrUsernameTaken {
t.Fatalf("Create with case-different duplicate = %v, want ErrUsernameTaken", err)
}
}
func TestUsersValidation(t *testing.T) {
u, _ := newTestUsers(t)
if _, err := u.Create("", "password"); err == nil {
t.Fatal("empty username should be rejected")
}
if _, err := u.Create("carol", ""); err == nil {
t.Fatal("empty password should be rejected")
}
if _, err := u.Create("dave", strings.Repeat("x", 73)); err != ErrPasswordTooLong {
t.Fatalf("73-byte password = %v, want ErrPasswordTooLong", err)
}
}
func TestUsersDeleteRefusesLastAccount(t *testing.T) {
u, _ := newTestUsers(t)
a, err := u.Create("alice", "password1")
if err != nil {
t.Fatalf("Create: %v", err)
}
b, err := u.Create("bob", "password2")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := u.Delete(a.ID); err != nil {
t.Fatalf("Delete first account: %v", err)
}
if err := u.Delete(b.ID); err != ErrLastAccount {
t.Fatalf("Delete last account = %v, want ErrLastAccount", err)
}
if u.Count() != 1 {
t.Fatalf("Count = %d, want 1", u.Count())
}
}
func TestUsersPersistAcrossReload(t *testing.T) {
u, path := newTestUsers(t)
if _, err := u.Create("alice", "correct-password"); err != nil {
t.Fatalf("Create: %v", err)
}
reopened, err := NewUsers(path)
if err != nil {
t.Fatalf("NewUsers: %v", err)
}
if reopened.Count() != 1 {
t.Fatalf("Count after reload = %d, want 1", reopened.Count())
}
if _, err := reopened.Verify("alice", "correct-password"); err != nil {
t.Fatalf("Verify after reload: %v", err)
}
}
func TestUsersTouchLastLogin(t *testing.T) {
u, _ := newTestUsers(t)
usr, err := u.Create("alice", "correct-password")
if err != nil {
t.Fatalf("Create: %v", err)
}
if usr.LastLoginAt != nil {
t.Fatal("a fresh account should have no last login")
}
if err := u.TouchLastLogin(usr.ID); err != nil {
t.Fatalf("TouchLastLogin: %v", err)
}
got, err := u.Get(usr.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.LastLoginAt == nil {
t.Fatal("LastLoginAt should be set after TouchLastLogin")
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<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-qcSVszEj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKzWD9Xt.css">
<script type="module" crossorigin src="/assets/index-4h69aJoO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjhDzpx2.css">
</head>
<body>
<div id="root"></div>
+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 {
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useState } from 'react'
import { api, ApiError } from './api'
import type { APIToken, Me } from './types'
import { Notice } from './ui'
export function Account({ me }: { me: Me }) {
const [tokens, setTokens] = useState<APIToken[]>([])
const [name, setName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Set only right after a create; the plaintext is never retrievable again.
const [revealed, setRevealed] = useState<{ name: string; token: string } | null>(null)
const load = useCallback(async () => {
try {
setTokens(await api.tokens())
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
useEffect(() => {
load()
}, [load])
const create = async () => {
const trimmed = name.trim()
if (!trimmed) return
setBusy(true)
setError('')
try {
const tok = await api.createToken(trimmed)
setRevealed({ name: tok.name, token: tok.token })
setName('')
await load()
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e))
} finally {
setBusy(false)
}
}
const revoke = async (id: string) => {
if (!confirm('Revoke this token? Anything using it stops working immediately.')) return
await api.revokeToken(id)
await load()
}
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 720 }}>
<div className="section" style={{ padding: 0, border: 'none' }}>
<h3>signed in as</h3>
<div className="mono">{me.username}</div>
</div>
<div className="section" style={{ padding: 0, border: 'none' }}>
<h3>personal API tokens</h3>
<div className="small faint">
For scripted access: pass a token as <span className="mono">X-Auth-Token</span> or{' '}
<span className="mono">Authorization: Bearer </span>.
</div>
</div>
{error && <Notice kind="err">{error}</Notice>}
{revealed && (
<Notice kind="warn">
Token <b>{revealed.name}</b> copy it now, it will not be shown again:
<div className="fingerprint" style={{ marginTop: 6 }}>{revealed.token}</div>
<button className="btn tiny" style={{ marginTop: 6 }} onClick={() => setRevealed(null)}>done</button>
</Notice>
)}
<div className="row" style={{ gap: 8 }}>
<input
type="text"
placeholder="token name, e.g. ci-backup"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && create()}
/>
<button className="btn primary" onClick={create} disabled={busy || !name.trim()}>create token</button>
</div>
{tokens.length === 0 ? (
<div className="empty">no personal tokens yet</div>
) : (
<table className="mount-table">
<thead>
<tr>
<th>name</th>
<th style={{ width: 90 }}>ends in</th>
<th style={{ width: 170 }}>created</th>
<th style={{ width: 170 }}>last used</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td className="mono">{t.hint}</td>
<td className="small faint">{new Date(t.createdAt).toLocaleString()}</td>
<td className="small faint">{t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'}</td>
<td>
<button className="btn tiny danger" onClick={() => revoke(t.id)}>revoke</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
+10 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from './api'
import type {
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, Source, SourceResponse,
Connection, Health, ItemSelection, JobSnapshot, Me, Options, PackageInfo, Plan, Source, SourceResponse,
SourceStatus, TargetInventory,
} from './types'
import { Notice } from './ui'
@@ -10,10 +10,11 @@ import { SourcePanel } from './SourcePanel'
import { Sidebar } from './Sidebar'
import { Jobs } from './Jobs'
import { Packages } from './Packages'
import { Account } from './Account'
type View = 'containers' | 'jobs' | 'packages'
type View = 'containers' | 'jobs' | 'packages' | 'account'
export default function App() {
export default function App({ me, logout }: { me: Me; logout: () => void }) {
const [health, setHealth] = useState<Health | null>(null)
const [source, setSource] = useState<SourceResponse | null>(null)
const [sel, setSel] = useState<Record<string, ItemSelection>>({})
@@ -174,6 +175,9 @@ export default function App() {
Packages
{packages.length > 0 && <span className="count">{packages.length}</span>}
</button>
<button className={`tab${view === 'account' ? ' active' : ''}`} onClick={() => setView('account')}>
Account
</button>
</nav>
<div className="topbar-right">
{health && (
@@ -187,6 +191,8 @@ export default function App() {
<button className="btn tiny" onClick={loadSource} disabled={loading}>
{loading ? 'loading…' : 'refresh'}
</button>
<span className="small faint">{me.username}</span>
<button className="btn tiny ghost" onClick={logout}>sign out</button>
</div>
</header>
@@ -232,6 +238,7 @@ export default function App() {
/>
)}
{view === 'packages' && <Packages packages={packages} reload={loadPackages} />}
{view === 'account' && <Account me={me} />}
</main>
{view === 'containers' && (
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState } from 'react'
import App from './App'
import { api, setUnauthorizedHandler } from './api'
import { Login } from './Login'
import type { Me } from './types'
/**
* AuthGate decides, on load and after every 401, whether to show the setup
* screen, a login form, or the app itself. It owns the one GET /api/me call
* the app needs before it can render anything real.
*/
export function AuthGate() {
const [me, setMe] = useState<Me | null>(null)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
setMe(await api.me())
setError('')
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
useEffect(() => {
load()
}, [load])
// A 401 elsewhere in the app (an expired or revoked session) means the
// server no longer considers us logged in; drop straight to the login form
// instead of waiting for the next unrelated re-render to notice.
useEffect(() => {
setUnauthorizedHandler(() => setMe((prev) => (prev ? { ...prev, authenticated: false } : prev)))
return () => setUnauthorizedHandler(null)
}, [])
const logout = useCallback(async () => {
await api.logout().catch(() => undefined)
await load()
}, [load])
if (!me) {
return <div className="empty">{error || 'loading…'}</div>
}
if (!me.authenticated) {
return <Login needsSetup={!!me.needsSetup} onDone={load} />
}
return <App me={me} logout={logout} />
}
+70
View File
@@ -0,0 +1,70 @@
import { useState, type FormEvent } from 'react'
import { api, ApiError } from './api'
import { Notice } from './ui'
/**
* Login is the one form for both first-run setup and every login after that:
* the server itself has no separate "register" concept, just an account
* store that starts empty.
*/
export function Login({ needsSetup, onDone }: { needsSetup: boolean; onDone: () => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
setBusy(true)
setError('')
try {
if (needsSetup) {
await api.setup(username, password)
} else {
await api.login(username, password)
}
onDone()
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
} finally {
setBusy(false)
}
}
return (
<div className="login">
<form className="login-box" onSubmit={submit}>
<div className="brand"><img src="/logo-icon.png" alt="" className="brand-logo" />DockMV</div>
<h1>{needsSetup ? 'Create your account' : 'Sign in'}</h1>
{needsSetup && (
<Notice kind="info">
No account exists yet. Create the first one to finish setup every account can manage every
other one, there are no separate roles yet.
</Notice>
)}
<label className="field">
<span>username</span>
<input
type="text" autoFocus autoComplete="username"
value={username} onChange={(e) => setUsername(e.target.value)} required
/>
</label>
<label className="field">
<span>password</span>
<input
type="password" autoComplete={needsSetup ? 'new-password' : 'current-password'}
value={password} onChange={(e) => setPassword(e.target.value)} required
/>
</label>
{error && <Notice kind="err">{error}</Notice>}
<button className="btn primary" type="submit" disabled={busy}>
{busy ? 'please wait…' : needsSetup ? 'create account' : 'sign in'}
</button>
</form>
</div>
)
}
+34 -24
View File
@@ -1,23 +1,8 @@
import type {
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory,
APIToken, Connection, Health, HostKeyInfo, JobSnapshot, Me, PackageInfo, Plan,
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory, User,
} from './types'
// The token, when the server requires one, arrives as a query parameter the
// first time and is kept for the tab afterwards.
function readToken(): string {
const fromUrl = new URLSearchParams(location.search).get('token')
if (fromUrl) {
sessionStorage.setItem('dm.token', fromUrl)
const clean = location.pathname + location.hash
history.replaceState(null, '', clean)
return fromUrl
}
return sessionStorage.getItem('dm.token') ?? ''
}
const token = readToken()
/** ApiError carries the server's message plus anything it attached to it. */
export class ApiError extends Error {
status: number
@@ -33,12 +18,26 @@ export class ApiError extends Error {
}
}
// Paths whose own job is to report or resolve "not authenticated" — a 401
// from one of these is the expected outcome of a bad login, not a session
// that dropped out from under the app, so it must not trigger the global
// handler below.
const authPaths = new Set(['/api/me', '/api/login', '/api/setup'])
// Set by AuthGate so a 401 on any other endpoint (a session that expired or
// was revoked mid-use) drops the app back to the login screen, without every
// call site having to check for it.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(fn: (() => void) | null) {
onUnauthorized = fn
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
if (token) headers['X-Auth-Token'] = token
if (init?.body) headers['Content-Type'] = 'application/json'
const res = await fetch(path, { ...init, headers })
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
if (res.status === 401 && !authPaths.has(path)) onUnauthorized?.()
if (res.status === 204) return undefined as T
const text = await res.text()
@@ -65,6 +64,19 @@ export const api = {
source: () => request<SourceResponse>('/api/source'),
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
me: () => request<Me>('/api/me'),
setup: (username: string, password: string) => post<Me>('/api/setup', { username, password }),
login: (username: string, password: string) => post<Me>('/api/login', { username, password }),
logout: () => request<void>('/api/logout', { method: 'POST' }),
users: () => request<User[]>('/api/users'),
createUser: (username: string, password: string) => post<User>('/api/users', { username, password }),
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
tokens: () => request<APIToken[]>('/api/tokens'),
createToken: (name: string) => post<APIToken & { token: string }>('/api/tokens', { name }),
revokeToken: (id: string) => request<void>(`/api/tokens/${id}`, { method: 'DELETE' }),
sources: () => request<SourcesResponse>('/api/sources'),
saveSource: (s: Partial<Source>) => post<Source>('/api/sources', s),
deleteSource: (id: string) => request<void>(`/api/sources/${id}`, { method: 'DELETE' }),
@@ -92,10 +104,8 @@ export const api = {
packages: () => request<PackageInfo[]>('/api/packages'),
deletePackage: (name: string) => request<void>(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }),
downloadUrl: (name: string) =>
`/api/packages/${encodeURIComponent(name)}/download` + (token ? `?token=${encodeURIComponent(token)}` : ''),
downloadUrl: (name: string) => `/api/packages/${encodeURIComponent(name)}/download`,
/** Opens the live progress stream for a job. */
jobEvents: (id: string) =>
new EventSource(`/api/jobs/${id}/events` + (token ? `?token=${encodeURIComponent(token)}` : '')),
/** Opens the live progress stream for a job. Cookies ride along automatically: it's a same-origin request. */
jobEvents: (id: string) => new EventSource(`/api/jobs/${id}/events`),
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { AuthGate } from './AuthGate'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<AuthGate />
</StrictMode>,
)
+11
View File
@@ -311,3 +311,14 @@ label.field > span { display: block; font-size: 11px; color: var(--text-dim); ma
background: var(--bg-sunken); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 8px 10px;
}
/* ---------- login ---------- */
.login { height: 100%; display: flex; align-items: center; justify-content: center; }
.login-box {
width: min(360px, 90vw); display: flex; flex-direction: column; gap: 14px;
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 28px;
}
.login-box .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.login-box h1 { font-size: 16px; font-weight: 600; margin: 0; }
+24 -1
View File
@@ -286,5 +286,28 @@ export interface Health {
packageDir: string
dataDir: string
knownHosts: string
authRequired: boolean
}
export interface User {
id: string
username: string
createdAt: string
lastLoginAt?: string
}
export interface APIToken {
id: string
userId: string
name: string
hint: string
createdAt: string
lastUsedAt?: string
}
/** The response from GET /api/me, /api/setup and /api/login. */
export interface Me {
id?: string
username?: string
authenticated: boolean
needsSetup?: boolean
}