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:
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -25,11 +25,10 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
|
||||
body := map[string]any{
|
||||
"ok": true,
|
||||
"packageDir": s.cfg.PackageDir,
|
||||
"dataDir": s.cfg.DataDir,
|
||||
"knownHosts": s.hosts.Path(),
|
||||
"authRequired": s.cfg.Token != "",
|
||||
"ok": true,
|
||||
"packageDir": s.cfg.PackageDir,
|
||||
"dataDir": s.cfg.DataDir,
|
||||
"knownHosts": s.hosts.Path(),
|
||||
}
|
||||
|
||||
// Health is also what connects to the selected source on a fresh start, so
|
||||
|
||||
+46
-47
@@ -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
|
||||
@@ -42,13 +40,17 @@ type Config struct {
|
||||
|
||||
// Server ties the source daemon, connection store and job manager to HTTP.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
sources *store.Sources
|
||||
conns *store.Connections
|
||||
hosts *sshx.KnownHosts
|
||||
jobs *job.Manager
|
||||
mux *http.ServeMux
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
sources *store.Sources
|
||||
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
|
||||
// first use and replaced when the operator picks another source.
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
KindSSH Kind = "ssh"
|
||||
KindPackage Kind = "package"
|
||||
KindRestore Kind = "restore"
|
||||
KindBackup Kind = "backup"
|
||||
)
|
||||
|
||||
// Level classifies a log line.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+11
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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' && (
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user