// Package admin implements the HTTP admin server: authenticated routes for // editing game data (rooms, items, mobs, drops, triggers, colors, …), the // embedded web UI, and the undo stack backing it. package admin import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "embed" "encoding/hex" "encoding/json" "errors" "fmt" "html/template" "io" "io/fs" "log" "math/big" "net" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/config" "thehouseoficarus/internal/item" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) //go:embed templates static var embedded embed.FS type AdminServer struct { cfg *config.Config useTLS bool accountStore *player.AccountStore world *world.World itemStore *item.ItemStore objectStore *object.ObjectStore mobStore *world.MobStore dataDir string httpServer *http.Server undoStack *UndoStack 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 // without the admin package depending on game. rewriteRoomIDs func(idMap map[int]int) // configPath is the on-disk config.yaml path, used by the color themer // to persist edited default_colors back to the file the game loaded. configPath string // evictRoom evicts cached in-room games (tables/machines) when a room // is deleted or renumbered: outstanding wagers are refunded through the // wired game callback before the cache entries drop. evictRoom func(roomID int) // rebuildCraftIndex rebuilds the in-game craft index after item files // change (create/update/delete/rename/move/undo/redo). Wired from the // Game so the web editor's item edits stay consistent with gameplay. rebuildCraftIndex func() // playerFileOnlineCheck reports whether the character/account addressed // by a data-relative player file path is currently online. Wired from the // game (SetPlayerFileOnlineCheck) so the raw file editor refuses to // overwrite a live character/account's file. Optional; nil disables it. playerFileOnlineCheck func(relPath string) bool // loginLimiterOnce/loginLimiter hold the bounded per-IP failed-login // throttle for /login. The limiter is created on first use so an // AdminServer built directly (tests) behaves like one from NewServer. loginLimiterOnce sync.Once loginLimiter *loginLimiter } // ServerDeps are the long-lived stores and wired callbacks the admin server // uses. It replaces a 15-argument positional constructor in which every // callback had the same func() shape, so a mis-ordered call compiled silently // and only failed at runtime. type ServerDeps struct { Config *config.Config UseTLS bool Accounts *player.AccountStore World *world.World Items *item.ItemStore Objects *object.ObjectStore Mobs *world.MobStore DataDir string ReloadCourses func() RewriteRoomIDs func(map[int]int) EvictRoom func(roomID int) RebuildCraftIndex func() ConfigPath string RunOnOwner func(func()) bool } func NewServer(deps ServerDeps) (*AdminServer, error) { if deps.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) } tmpl, err := template.New("").Funcs(template.FuncMap{ "json": func(v any) string { b, _ := json.Marshal(v) return string(b) }, }).ParseFS(embedded, "templates/*.html") if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } var port int if deps.UseTLS { port = deps.Config.AdminHTTPS.Port } else { port = deps.Config.AdminHTTP.Port } var cert tls.Certificate if deps.UseTLS { cert, err = loadOrGenerateCert(deps.Config) if err != nil { return nil, fmt.Errorf("admin cert: %w", err) } } s := &AdminServer{ cfg: deps.Config, useTLS: deps.UseTLS, accountStore: deps.Accounts, world: deps.World, itemStore: deps.Items, objectStore: deps.Objects, mobStore: deps.Mobs, dataDir: deps.DataDir, undoStack: NewUndoStack(deps.DataDir), tmpl: tmpl, cookieSecret: secret, reloadCourses: deps.ReloadCourses, rewriteRoomIDs: deps.RewriteRoomIDs, evictRoom: deps.EvictRoom, rebuildCraftIndex: deps.RebuildCraftIndex, configPath: deps.ConfigPath, runOnOwner: deps.RunOnOwner, } // Undo/redo of room-ID changes applies the (inverse) runtime migration // through the same wired callback the rename uses. s.undoStack.SetMigrateRoomIDs(deps.RewriteRoomIDs) mux := http.NewServeMux() staticFS, err := fs.Sub(embedded, "static") if err != nil { return nil, fmt.Errorf("static subfs: %w", err) } // Serve embedded assets by exact name: this both requires auth (except // for the stylesheet the unauthenticated login page needs) and never // emits the http.FileServer directory listing that leaked the UI's // module names. mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/static/") if name != "admin.css" && !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } data, err := fs.ReadFile(staticFS, name) if err != nil { http.NotFound(w, r) return } http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data)) }) mux.HandleFunc("/login", s.handleLogin) mux.HandleFunc("/logout", s.handleLogout) apiMux := http.NewServeMux() apiMux.HandleFunc("/api/map", s.handleMap) apiMux.HandleFunc("/api/room-dirs", s.handleRoomDirs) apiMux.HandleFunc("/api/room-dirs/rename", s.handleRenameRoomDir) apiMux.HandleFunc("/api/room-dirs/create", s.handleCreateRoomDir) apiMux.HandleFunc("/api/rooms/link", s.handleRoomLink) apiMux.HandleFunc("/api/rooms/insert", s.handleRoomInsert) apiMux.HandleFunc("/api/rooms/remove", s.handleRoomRemove) apiMux.HandleFunc("/api/rooms/move", s.handleRoomMove) apiMux.HandleFunc("/api/rooms/rename", s.handleRoomRename) apiMux.HandleFunc("/api/rooms/swap", s.handleRoomSwap) apiMux.HandleFunc("/api/rooms/bulk-edit", s.handleBulkEdit) apiMux.HandleFunc("/api/rooms/bulk-delete", s.handleBulkDelete) apiMux.HandleFunc("/api/rooms", s.handleRooms) apiMux.HandleFunc("/api/rooms/new-empty", s.handleCreateEmptyRoom) apiMux.HandleFunc("/api/rooms/", s.handleRoomByID) apiMux.HandleFunc("/api/objects/rename", s.handleEntityRename("objects")) apiMux.HandleFunc("/api/objects/move", s.handleEntityMove("objects")) apiMux.HandleFunc("/api/objects/dirs", s.handleEntityDirs("objects")) apiMux.HandleFunc("/api/objects", s.handleEntityList(objectSpec)) apiMux.HandleFunc("/api/objects/", s.handleEntityByID(objectSpec)) apiMux.HandleFunc("/api/items/rename", s.handleEntityRename("items")) apiMux.HandleFunc("/api/items/move", s.handleEntityMove("items")) apiMux.HandleFunc("/api/items/dirs", s.handleEntityDirs("items")) apiMux.HandleFunc("/api/items", s.handleEntityList(itemSpec)) apiMux.HandleFunc("/api/items/", s.handleEntityByID(itemSpec)) apiMux.HandleFunc("/api/mobs/rename", s.handleEntityRename("mobs")) apiMux.HandleFunc("/api/mobs/move", s.handleEntityMove("mobs")) apiMux.HandleFunc("/api/mobs/dirs", s.handleEntityDirs("mobs")) apiMux.HandleFunc("/api/mobs", s.handleEntityList(mobSpec)) apiMux.HandleFunc("/api/mobs/", s.handleEntityByID(mobSpec)) apiMux.HandleFunc("/api/drops/rename", s.handleEntityRename("drops")) apiMux.HandleFunc("/api/drops/move", s.handleEntityMove("drops")) apiMux.HandleFunc("/api/drops/dirs", s.handleEntityDirs("drops")) apiMux.HandleFunc("/api/drops", s.handleEntityList(dropSpec)) apiMux.HandleFunc("/api/drops/", s.handleEntityByID(dropSpec)) apiMux.HandleFunc("/api/hazards/rename", s.handleEntityRename("hazards")) apiMux.HandleFunc("/api/hazards/move", s.handleEntityMove("hazards")) apiMux.HandleFunc("/api/hazards/dirs", s.handleEntityDirs("hazards")) apiMux.HandleFunc("/api/hazards", s.handleEntityList(hazardSpec)) apiMux.HandleFunc("/api/hazards/", s.handleEntityByID(hazardSpec)) apiMux.HandleFunc("/api/techs/rename", s.handleEntityRename("techs")) apiMux.HandleFunc("/api/techs/move", s.handleEntityMove("techs")) apiMux.HandleFunc("/api/techs/dirs", s.handleEntityDirs("techs")) apiMux.HandleFunc("/api/techs", s.handleEntityList(techSpec)) apiMux.HandleFunc("/api/techs/", s.handleEntityByID(techSpec)) apiMux.HandleFunc("/api/modules/rename", s.handleEntityRename("modules")) apiMux.HandleFunc("/api/modules/move", s.handleEntityMove("modules")) apiMux.HandleFunc("/api/modules/dirs", s.handleEntityDirs("modules")) apiMux.HandleFunc("/api/modules", s.handleEntityList(moduleSpec)) apiMux.HandleFunc("/api/modules/", s.handleEntityByID(moduleSpec)) apiMux.HandleFunc("/api/courses", s.handleCourses) apiMux.HandleFunc("/api/courses/", s.handleCourseByID) apiMux.HandleFunc("/api/players", s.handlePlayers) apiMux.HandleFunc("/api/players/", s.handlePlayerByID) apiMux.HandleFunc("/api/dashboard", s.handleDashboard) apiMux.HandleFunc("/api/global_flags", s.handleGlobalFlags) apiMux.HandleFunc("/api/tools", s.handleTools) apiMux.HandleFunc("/api/stations", s.handleStations) apiMux.HandleFunc("/api/search", s.handleSearch) apiMux.HandleFunc("/api/undo/state", s.handleUndoState) apiMux.HandleFunc("/api/undo/undo", s.doUndo) apiMux.HandleFunc("/api/undo/redo", s.doRedo) apiMux.HandleFunc("/api/next-room-id", s.handleNextRoomID) apiMux.HandleFunc("/api/files", s.handleFiles) apiMux.HandleFunc("/api/files/rename", s.handleFileRename) apiMux.HandleFunc("/api/duplicate-rooms", s.handleDuplicateRooms) apiMux.HandleFunc("/api/rooms/resolve-duplicate", s.handleResolveDuplicate) apiMux.HandleFunc("/api/triggers", s.handleTriggers) apiMux.HandleFunc("/api/triggers/", s.handleTriggerByID) apiMux.HandleFunc("/api/colors", s.handleColors) apiMux.HandleFunc("/api/colors/reset", s.handleColorsReset) mux.Handle("/api/", s.authMiddleware(s.ownerMiddleware(apiMux))) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Redirect(w, r, "/login", http.StatusFound) return } page := strings.TrimPrefix(r.URL.Path, "/") if page == "" { page = "map" } if page == "map" { dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) if len(dups) > 0 { data := map[string]any{ "Account": s.accountFromCookie(r), "UndoStack": s.undoStack.Info(), "Page": "duplicate-rooms", } s.renderPage(w, r, "layout", data) return } } data := map[string]any{ "Account": s.accountFromCookie(r), "UndoStack": s.undoStack.Info(), "Page": page, } s.renderPage(w, r, "layout", data) }) mux.HandleFunc("/editor/", func(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Redirect(w, r, "/login", http.StatusFound) return } page := strings.TrimPrefix(r.URL.Path, "/editor/") if page == "map" { dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) if len(dups) > 0 { data := map[string]any{ "Account": s.accountFromCookie(r), "UndoStack": s.undoStack.Info(), "Page": "duplicate-rooms", } s.renderPage(w, r, "layout", data) return } } data := map[string]any{ "Account": s.accountFromCookie(r), "UndoStack": s.undoStack.Info(), "Page": page, } s.renderPage(w, r, "layout", data) }) s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second, } if deps.UseTLS { s.httpServer.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, } } return s, nil } // SetPlayerFileOnlineCheck wires the online-session check used by the raw // file editor to refuse editing/deleting/renaming a character or account file // while that character/account is online. It is optional: when unset the // editor behaves as before. main.go wires it to a game-side lookup. // // Undo/redo writes the same files through the undo stack, so the hook is // handed to it as well: undoing an edit to a live character's file must be // refused like the edit itself. func (s *AdminServer) SetPlayerFileOnlineCheck(fn func(relPath string) bool) { s.playerFileOnlineCheck = fn if s.undoStack != nil { s.undoStack.SetPlayerFileOnlineCheck(fn) } } // 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 { return fmt.Errorf("admin listen: %w", err) } if s.useTLS { tlsLn := tls.NewListener(ln, s.httpServer.TLSConfig) return s.httpServer.Serve(tlsLn) } return s.httpServer.Serve(ln) } func (s *AdminServer) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } // UndoStack returns the server's undo history. Two admin transports over the // same data directory must share one stack: each server otherwise loads and // rewrites the same history file independently, so records made through one // are silently dropped by the next write from the other. func (s *AdminServer) UndoStack() *UndoStack { return s.undoStack } // SetUndoStack replaces the server's undo history with a shared stack. Call // before serving requests. func (s *AdminServer) SetUndoStack(us *UndoStack) { if us != nil { s.undoStack = us } } func (s *AdminServer) checkAuth(r *http.Request) bool { cookie, err := r.Cookie("admin_session") if err != nil { return false } parts := strings.SplitN(cookie.Value, ":", 3) if len(parts) != 3 { return false } account := parts[0] // The account name is attacker-controlled (it comes from the cookie) and // is joined into players/accounts/.yaml by isAdminAccount below, // so reject path-like names before the first join. if err := player.ValidName(account); err != nil { return false } issued, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { return false } issuedAt := time.Unix(issued, 0) if time.Since(issuedAt) > adminSessionTTL || time.Until(issuedAt) > time.Minute { return false } sig := parts[2] if !s.isAdminAccount(account) { return false } // Bind the signature to the current password hash as well as the issue // time, so changing an admin password invalidates every previously issued // cookie instead of leaving it valid for the full TTL. The admin check // above already loaded the account; reload it here for the hash. acc, err := s.accountStore.LoadAccount(account) if err != nil || !acc.Admin { return false } mac := hmac.New(sha256.New, s.cookieSecret) mac.Write([]byte(account + ":" + parts[1] + ":" + acc.PasswordHash)) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(sig), []byte(expected)) } func (s *AdminServer) isAdminAccount(name string) bool { acc, err := s.accountStore.LoadAccount(name) if err != nil { return false } return acc.Admin } // adminSessionTTL bounds how long a signed admin cookie is accepted. The // signature covers the issue time, so a leaked cookie cannot authenticate // indefinitely and the server can age it out without server-side state. const adminSessionTTL = 24 * time.Hour // maxLoginBodyBytes bounds the unauthenticated /login request body before any // form or multipart parsing. 64 KiB is far above any real login form. const maxLoginBodyBytes = 64 << 10 // Failed-login throttling for the unauthenticated /login route. The telnet // login path is deliberately NOT covered: net.Conn/Session expose no client // identity, so there is nothing to key a limit on. const ( // adminLoginMaxFailures is the number of failed attempts an IP may make // before it is refused. adminLoginMaxFailures = 5 // adminLoginCooldown is how long a refused IP stays refused. adminLoginCooldown = 5 * time.Minute // adminLoginMaxTracked caps the tracked client IPs, so a source that // cycles addresses cannot grow server memory without bound. adminLoginMaxTracked = 1024 ) // loginLimiter is a small, bounded, in-memory failed-login limiter keyed by // client IP. After adminLoginMaxFailures consecutive failures an IP is // refused for adminLoginCooldown; a successful login clears its state. The // entry map holds at most adminLoginMaxTracked keys (the least recently seen // entry without an active cooldown is evicted first), and every method is // safe for concurrent use: /login is unauthenticated and served by many // goroutines at once. // // now is the limiter's clock, read under mu on every call. Tests replace it // once, before the server serves a request, to drive the cooldown // deterministically; production leaves it at time.Now. type loginLimiter struct { mu sync.Mutex entries map[string]loginAttempts now func() time.Time } type loginAttempts struct { failures int inflight int blockedTill time.Time lastSeen time.Time } func newLoginLimiter() *loginLimiter { return &loginLimiter{entries: make(map[string]loginAttempts), now: time.Now} } // begin atomically checks the cooldown and reserves an in-flight slot for // ip. Reserving at admission time closes the TOCTOU where a concurrent burst // all passes allow() before any of them records a failure: the number of // simultaneous verifications for one address is bounded by the same // threshold. Every successful begin must be paired with finish. func (l *loginLimiter) begin(ip string) bool { l.mu.Lock() defer l.mu.Unlock() now := l.now() e, ok := l.entries[ip] if !ok { if !l.makeRoomLocked(now) { // The tracked map is full of cooling-down addresses; do not // reserve (preserves the existing bounded-map behavior). return true } e = loginAttempts{} } if now.Before(e.blockedTill) { return false } if e.failures+e.inflight >= adminLoginMaxFailures { return false } e.inflight++ e.lastSeen = now l.entries[ip] = e return true } // finish releases the in-flight slot reserved by begin and records the // outcome: success clears the address, failure advances the cooldown. func (l *loginLimiter) finish(ip string, ok bool) { l.mu.Lock() defer l.mu.Unlock() now := l.now() e, tracked := l.entries[ip] if !tracked { return } if e.inflight > 0 { e.inflight-- } e.lastSeen = now if ok { delete(l.entries, ip) return } if now.Before(e.blockedTill) { l.entries[ip] = e return } e.failures++ if e.failures >= adminLoginMaxFailures { e.blockedTill = now.Add(adminLoginCooldown) e.failures = 0 } l.entries[ip] = e } // allow reports whether ip may attempt a login now. func (l *loginLimiter) allow(ip string) bool { l.mu.Lock() defer l.mu.Unlock() e, ok := l.entries[ip] if !ok { return true } return !l.now().Before(e.blockedTill) } // fail records a failed attempt for ip and starts the cooldown once the // threshold is reached. A still-blocked IP keeps its existing cooldown: a // blocked source cannot extend its own block by hammering the route, and an // address that cannot be tracked while every tracked address is cooling down // is left untracked rather than evicting a live cooldown. func (l *loginLimiter) fail(ip string) { l.mu.Lock() defer l.mu.Unlock() now := l.now() e, ok := l.entries[ip] if !ok && !l.makeRoomLocked(now) { return } e.lastSeen = now if now.Before(e.blockedTill) { l.entries[ip] = e return } e.failures++ if e.failures >= adminLoginMaxFailures { e.blockedTill = now.Add(adminLoginCooldown) e.failures = 0 } l.entries[ip] = e } // succeed clears ip's failure state after a successful login. func (l *loginLimiter) succeed(ip string) { l.mu.Lock() defer l.mu.Unlock() delete(l.entries, ip) } // makeRoomLocked makes room for one new tracked address once the map is at // its cap, reporting false when it cannot. Fully expired entries (no cooldown // left and no failures counted) go first; if the map is still full the least // recently seen entry without an active cooldown is dropped. // // An entry with an active cooldown is NEVER evicted: a blocked source must not // be able to unblock itself by filling the map with other addresses, which is // exactly what dropping its (oldest) entry would hand it. When every tracked // address is still cooling down the map stays bounded and the new address is // simply not tracked. func (l *loginLimiter) makeRoomLocked(now time.Time) bool { if len(l.entries) < adminLoginMaxTracked { return true } for ip, e := range l.entries { if e.failures == 0 && e.inflight == 0 && !now.Before(e.blockedTill) { delete(l.entries, ip) } } for len(l.entries) >= adminLoginMaxTracked { oldest, found := "", false for ip, e := range l.entries { if now.Before(e.blockedTill) { continue } if !found || e.lastSeen.Before(l.entries[oldest].lastSeen) { oldest, found = ip, true } } if !found { return false } delete(l.entries, oldest) } return true } // limitLogins returns the server's login limiter, creating it on first use. func (s *AdminServer) limitLogins() *loginLimiter { s.loginLimiterOnce.Do(func() { s.loginLimiter = newLoginLimiter() }) return s.loginLimiter } // clientIP keys the login limiter by the request's client address. RemoteAddr // is "host:port" for TCP transports; anything without a port (synthetic // requests, unix sockets) is used verbatim. Forwarded headers are ignored: // they are client-controlled and would let one source pose as many. func clientIP(remoteAddr string) string { if host, _, err := net.SplitHostPort(remoteAddr); err == nil { return host } return remoteAddr } func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) { // Include the current password hash in the MAC so a password change // revokes outstanding cookies (see checkAuth). hash := "" if acc, err := s.accountStore.LoadAccount(account); err == nil { hash = acc.PasswordHash } issued := strconv.FormatInt(time.Now().Unix(), 10) mac := hmac.New(sha256.New, s.cookieSecret) mac.Write([]byte(account + ":" + issued + ":" + hash)) sig := hex.EncodeToString(mac.Sum(nil)) http.SetCookie(w, &http.Cookie{ Name: "admin_session", Value: account + ":" + issued + ":" + sig, Path: "/", HttpOnly: true, Secure: s.useTLS, SameSite: http.SameSiteStrictMode, MaxAge: int(adminSessionTTL / time.Second), }) } func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { data := map[string]any{} s.renderPage(w, r, "login.html", data) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Refuse a cooling-down source before reading its body: the limit exists // to make password guessing expensive, so it must not cost a parse. limiter := s.limitLogins() ip := clientIP(r.RemoteAddr) if !limiter.begin(ip) { http.Error(w, "too many failed login attempts; try again later", http.StatusTooManyRequests) return } loginOK := false defer func() { limiter.finish(ip, loginOK) }() // /login is registered before authMiddleware, so bound and buffer its body // here before r.FormValue can parse it: without the cap a multipart POST // spills unbounded data to temp files (ParseMultipartForm's own 32 MiB cap // only bounds memory). body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxLoginBodyBytes)) r.Body.Close() if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) return } http.Error(w, "cannot read request body", http.StatusBadRequest) return } r.Body = io.NopCloser(bytes.NewReader(body)) account := strings.TrimSpace(r.FormValue("account")) password := r.FormValue("password") // Validate before the name is joined into a filesystem path: LoadAccount // does no validation, so an unvalidated name is an arbitrary-*.yaml read // primitive ("account=../../rooms/1"). Every other account-name entry // point validates first. if err := player.ValidName(account); err != nil { s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."}) return } acc, err := s.accountStore.LoadAccount(account) if err != nil || !player.CheckPassword(password, acc.PasswordHash) { s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."}) return } 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) { 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) } } }) { writeJSONError(w, "game is shutting down", http.StatusServiceUnavailable) return } if !authorized { http.Error(w, "account changed; please sign in again", http.StatusUnauthorized) return } s.setAuthCookie(w, account) loginOK = true http.Redirect(w, r, "/", http.StatusFound) } func (s *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "admin_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.useTLS, }) http.Redirect(w, r, "/login", http.StatusFound) } func (s *AdminServer) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { if data == nil { data = map[string]any{} } if _, ok := data["Account"]; !ok { if acc := s.accountFromCookie(r); acc != "" { data["Account"] = acc } } if _, ok := data["UndoStack"]; !ok { data["UndoStack"] = s.undoStack.Info() } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { log.Printf("template error (%s): %v", name, err) } } func (s *AdminServer) accountFromCookie(r *http.Request) string { cookie, err := r.Cookie("admin_session") if err != nil { return "" } parts := strings.SplitN(cookie.Value, ":", 2) if len(parts) != 2 { return "" } return parts[0] } func loadOrGenerateCert(cfg *config.Config) (tls.Certificate, error) { // A half-configured cert pair must fail loudly instead of silently serving // a self-signed certificate (see the game-server equivalent). if (cfg.TLS.CertFile == "") != (cfg.TLS.KeyFile == "") { return tls.Certificate{}, fmt.Errorf("admin_https: tls.cert_file and tls.key_file must be set together") } if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) } log.Printf("admin_https: using self-signed certificate (no shared tls cert configured)") return generateSelfSignedCert() } func generateSelfSignedCert() (tls.Certificate, error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err) } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return tls.Certificate{}, fmt.Errorf("serial number: %w", err) } now := time.Now() tmpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: "THOI Admin Self-Signed Certificate", }, NotBefore: now.Add(-1 * time.Hour), NotAfter: now.Add(365 * 24 * time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, } certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err) } return tls.Certificate{ Certificate: [][]byte{certDER}, PrivateKey: key, }, nil } func writeJSON(w http.ResponseWriter, data any) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(data); err != nil { log.Printf("json encode error: %v", err) } } func writeJSONError(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]any{"error": msg}) } // failJSON writes a JSON error body without changing the HTTP status. This // is the long-standing wire behavior most admin editors depend on: they read // res.error out of a 200 response, while api wrappers in admin.js treat any // non-200 as a thrown raw-text error. Unifying status codes requires a // coordinated change across the static JS clients; do not silently switch // call sites to non-200 codes here. func failJSON(w http.ResponseWriter, msg string) { writeJSONError(w, msg, http.StatusOK) } func readJSON(r *http.Request, v any) error { body, err := io.ReadAll(r.Body) if err != nil { return err } defer r.Body.Close() return json.Unmarshal(body, v) } func (s *AdminServer) handleUndoState(w http.ResponseWriter, r *http.Request) { writeJSON(w, s.undoStack.Info()) } func (s *AdminServer) doUndo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } change := s.undoStack.Undo() if change == nil { writeJSON(w, map[string]any{"message": "Nothing to undo"}) return } s.rebuildAfterUndo(change) resp := map[string]any{"message": "Undid: " + change.ShortDesc()} dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) if len(dups) > 0 { resp["has_duplicates"] = true } writeJSON(w, resp) } func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } change := s.undoStack.Redo() if change == nil { writeJSON(w, map[string]any{"message": "Nothing to redo"}) return } s.rebuildAfterUndo(change) resp := map[string]any{"message": "Redid: " + change.ShortDesc()} dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) if len(dups) > 0 { resp["has_duplicates"] = true } writeJSON(w, resp) } func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Action string `json:"action"` Name string `json:"name"` Dir string `json:"dir"` NewName string `json:"new_name"` } if err := readJSON(r, &body); err != nil { failJSON(w, "invalid") return } switch body.Action { case "create": if err := createEntityDir(s.dataDir, entityType, body.Name); err != nil { failJSON(w, err.Error()) return } writeJSON(w, map[string]any{"ok": true, "name": body.Name}) case "delete": changes, err := deleteEntityDir(s.dataDir, entityType, body.Dir) if err != nil { failJSON(w, err.Error()) return } for _, ch := range changes { s.undoStack.Push(ch) } writeJSON(w, map[string]any{"ok": true, "moved": len(changes) - 1}) case "rename_dir": if err := renameEntityDir(s.dataDir, entityType, body.Dir, body.NewName); err != nil { failJSON(w, err.Error()) return } // Undo history stores absolute paths; re-prefix them so a later // undo does not recreate the old entity directory. if s.undoStack != nil { oldPrefix := filepath.Join(s.dataDir, entityType, body.Dir) newPrefix := filepath.Join(s.dataDir, entityType, body.NewName) s.undoStack.RewritePaths(oldPrefix, newPrefix) } writeJSON(w, map[string]any{"ok": true, "name": body.NewName}) default: failJSON(w, "unknown action") } } } func (s *AdminServer) handleEntityRename(entityType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { ID string `json:"id"` NewID string `json:"new_id"` } if err := readJSON(r, &body); err != nil || !validEntityID(body.ID) || !validEntityID(body.NewID) { failJSON(w, "invalid") return } oldPath, newPath, data, err := renameEntityFile(s.dataDir, entityType, body.ID, body.NewID) if err != nil { failJSON(w, err.Error()) return } desc := fmt.Sprintf("rename %s %s to %s", entityType, body.ID, body.NewID) s.undoStack.Push(ChangeDesc{ Description: desc, FilePath: oldPath, NewFilePath: newPath, OldContent: data, NewContent: data, }) s.invalidateEntityCaches(entityType) writeJSON(w, map[string]any{"ok": true, "id": body.NewID}) } } func (s *AdminServer) handleEntityMove(entityType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { ID string `json:"id"` Dir string `json:"dir"` } if err := readJSON(r, &body); err != nil || !validEntityID(body.ID) { failJSON(w, "invalid") return } oldPath, newPath, data, err := moveEntityFile(s.dataDir, entityType, body.ID, body.Dir) if err != nil { failJSON(w, err.Error()) return } desc := fmt.Sprintf("move %s %s to %s/", entityType, body.ID, body.Dir) if body.Dir == "" { desc = fmt.Sprintf("move %s %s to root", entityType, body.ID) } s.undoStack.Push(ChangeDesc{ Description: desc, FilePath: oldPath, NewFilePath: newPath, OldContent: data, NewContent: data, }) s.invalidateEntityCaches(entityType) writeJSON(w, map[string]any{"ok": true}) } } func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { s.world.RebuildRoomIndex(s.dataDir) s.world.ClearHazardCache() for _, p := range changeFiles(change) { rel, err := filepath.Rel(filepath.Join(s.dataDir, "rooms"), p) if err != nil { continue } if rel == "." || rel == ".." || strings.HasPrefix(rel, "..") { continue } name := filepath.Base(p) if idStr := strings.TrimSuffix(name, ".yaml"); idStr != name { if id, err := strconv.Atoi(idStr); err == nil && id > 0 { // Only drop runtime room state when the room file is now gone // (undo-of-create, redo-of-delete, directory deletion): a // rename's undo restores the file and the inverse migration // rekeys its runtime state back — clearing it here would // destroy the room's live contents. if _, statErr := os.Stat(p); os.IsNotExist(statErr) { s.world.ClearRoomState(id) // Drop any in-memory mob instances too so they don't keep // ticking against a room that no longer exists. if s.mobStore != nil { s.mobStore.RemoveMobsInRoom(id) } // The room no longer exists, so its cached casino games // (tables/machines) must be evicted like the direct-delete // API does: outstanding wagers are refunded through the // wired game callback before the cache entries drop. if s.evictRoom != nil { s.evictRoom(id) } } } } } for _, p := range changeFiles(change) { s.invalidateEntityCacheForPath(p) } // Course definitions changed on disk (create/update/delete via undo or // redo): the runtime course index must reload, exactly like the course // API handlers do, or gameplay keeps traversing the stale course. for _, p := range changeFiles(change) { if rel, err := filepath.Rel(filepath.Join(s.dataDir, "courses"), p); err == nil && !strings.HasPrefix(rel, "..") && rel != "." { s.reloadCoursesIfWired() break } } } func (s *AdminServer) invalidateEntityCaches(entityType string) { switch entityType { case "mobs": s.mobStore.ReloadDefs(s.dataDir) case "drops": behavior.ClearDropIndex() case "items": if s.itemStore != nil { s.itemStore.Reload(s.dataDir) } if s.rebuildCraftIndex != nil { s.rebuildCraftIndex() } } } func (s *AdminServer) invalidateEntityCacheForPath(path string) { for _, subdir := range []string{"mobs", "drops", "items"} { rel, err := filepath.Rel(filepath.Join(s.dataDir, subdir), path) if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { s.invalidateEntityCaches(subdir) } } } // pathUnderDir reports whether path lies inside dataDir/subdir (and is not the // directory itself). Used by the raw-rename path to refresh caches that the // shared invalidateEntityCacheForPath does not own. func pathUnderDir(dataDir, subdir, path string) bool { rel, err := filepath.Rel(filepath.Join(dataDir, subdir), path) return err == nil && rel != "." && !strings.HasPrefix(rel, "..") } func changeFiles(change *ChangeDesc) []string { files := []string{change.FilePath} if change.NewFilePath != "" { files = append(files, change.NewFilePath) } for _, ef := range change.ExtraFiles { files = append(files, ef.FilePath) } return files }