aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/server.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/server.go')
-rw-r--r--internal/admin/server.go62
1 files changed, 48 insertions, 14 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 81f0cce..3dbe8ad 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -50,6 +50,10 @@ type AdminServer struct {
tmpl *template.Template
cookieSecret []byte
reloadCourses func()
+ // runOnOwner synchronously executes an API task, or returns false when
+ // the game is shutting down. All runtime callbacks below run INSIDE
+ // that task and must not dispatch to the owner again.
+ runOnOwner func(func()) bool
// rewriteRoomIDs migrates room-ID-embedded state across all characters
// (online + offline) when a room ID changes. Wired from the Game so the
// admin web GUI can trigger the same migration as the swapid command
@@ -69,7 +73,10 @@ type AdminServer struct {
rebuildCraftIndex func()
}
-func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), evictRoom func(roomID int), rebuildCraftIndex func(), configPath string) (*AdminServer, error) {
+func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), evictRoom func(roomID int), rebuildCraftIndex func(), configPath string, runOnOwner func(func()) bool) (*AdminServer, error) {
+ if runOnOwner == nil {
+ return nil, fmt.Errorf("admin server requires an owner dispatcher")
+ }
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("cookie secret: %w", err)
@@ -117,6 +124,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
evictRoom: evictRoom,
rebuildCraftIndex: rebuildCraftIndex,
configPath: configPath,
+ runOnOwner: runOnOwner,
}
// Undo/redo of room-ID changes applies the (inverse) runtime migration
// through the same wired callback the rename uses.
@@ -206,7 +214,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/colors", s.handleColors)
apiMux.HandleFunc("/api/colors/reset", s.handleColorsReset)
- mux.Handle("/api/", s.authMiddleware(apiMux))
+ mux.Handle("/api/", s.authMiddleware(s.ownerMiddleware(apiMux)))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
@@ -264,8 +272,12 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
})
s.httpServer = &http.Server{
- Addr: fmt.Sprintf(":%d", port),
- Handler: mux,
+ Addr: fmt.Sprintf(":%d", port),
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ IdleTimeout: 60 * time.Second,
}
if useTLS {
s.httpServer.TLSConfig = &tls.Config{
@@ -277,6 +289,11 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
return s, nil
}
+// ServeHTTP exposes the same authenticated routes used by ListenAndServe.
+func (s *AdminServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.httpServer.Handler.ServeHTTP(w, r)
+}
+
func (s *AdminServer) ListenAndServe() error {
ln, err := net.Listen("tcp", s.httpServer.Addr)
if err != nil {
@@ -364,24 +381,41 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
- // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to
- // Argon2id after a successful verification.
+ if !acc.Admin {
+ s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."})
+ return
+ }
+
+ // Expensive password hashing stays off the owner. Reload the account
+ // inside the task so an upgrade cannot overwrite a concurrent character
+ // rename/create or a password/admin change made since authentication.
+ var upgradedHash string
if player.NeedsRehash(acc.PasswordHash) {
- if hash, hashErr := player.HashPassword(password); hashErr == nil {
- acc.PasswordHash = hash
- if err := s.accountStore.SaveAccount(acc); err != nil {
+ upgradedHash, _ = player.HashPassword(password)
+ }
+ authorized := false
+ if !s.runOnOwner(func() {
+ current, err := s.accountStore.LoadAccount(account)
+ if err != nil || !current.Admin || current.PasswordHash != acc.PasswordHash {
+ return
+ }
+ authorized = true
+ if upgradedHash != "" {
+ current.PasswordHash = upgradedHash
+ if err := s.accountStore.SaveAccount(current); err != nil {
log.Printf("admin login: password rehash save for %s: %v", account, err)
}
}
+ s.undoStack.Clear()
+ }) {
+ writeJSONError(w, "game is shutting down", http.StatusServiceUnavailable)
+ return
}
-
- if !acc.Admin {
- s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."})
+ if !authorized {
+ http.Error(w, "account changed; please sign in again", http.StatusUnauthorized)
return
}
-
s.setAuthCookie(w, account)
- s.undoStack.Clear()
http.Redirect(w, r, "/", http.StatusFound)
}