package api import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "log" "net/http" "strconv" "strings" "sync" "time" "halcyon/store" ) // The admin interface is for the operator. It has its own login, a single password // from the environment, and a signed, expiring session cookie. If no password is // configured the whole interface is off. const ( adminCookie = "halcyon_admin" adminSessionTTL = 8 * time.Hour adminMaxFails = 5 adminLockout = 15 * time.Minute ) type adminAuth struct { pwHash [32]byte key []byte mu sync.Mutex fails map[string]*failures } type failures struct { n int first time.Time until time.Time // locked out until then } func newAdminAuth(password string) *adminAuth { h := sha256.Sum256([]byte(password)) // The session key is derived from the password, so changing the password // ends every session, and nothing needs storing. mac := hmac.New(sha256.New, h[:]) mac.Write([]byte("halcyon admin session v1")) return &adminAuth{pwHash: h, key: mac.Sum(nil), fails: map[string]*failures{}} } func (a *adminAuth) passwordOK(pw string) bool { h := sha256.Sum256([]byte(pw)) return subtle.ConstantTimeCompare(h[:], a.pwHash[:]) == 1 } func (a *adminAuth) sign(expiry string) string { mac := hmac.New(sha256.New, a.key) mac.Write([]byte(expiry)) return hex.EncodeToString(mac.Sum(nil)) } // token is ".". func (a *adminAuth) token(now time.Time) string { exp := strconv.FormatInt(now.Add(adminSessionTTL).Unix(), 10) return exp + "." + a.sign(exp) } func (a *adminAuth) valid(tok string, now time.Time) bool { exp, mac, ok := strings.Cut(tok, ".") if !ok { return false } want := a.sign(exp) if subtle.ConstantTimeCompare([]byte(mac), []byte(want)) != 1 { return false } t, err := strconv.ParseInt(exp, 10, 64) return err == nil && now.Unix() < t } // lockedOut reports whether ip has failed too often recently, and for how much longer. func (a *adminAuth) lockedOut(ip string, now time.Time) (time.Duration, bool) { a.mu.Lock() defer a.mu.Unlock() f := a.fails[ip] if f == nil { return 0, false } if now.After(f.until) && now.Sub(f.first) > adminLockout { delete(a.fails, ip) // an old record return 0, false } if now.Before(f.until) { return f.until.Sub(now), true } return 0, false } func (a *adminAuth) recordFailure(ip string, now time.Time) { a.mu.Lock() defer a.mu.Unlock() f := a.fails[ip] if f == nil || now.Sub(f.first) > adminLockout { f = &failures{first: now} a.fails[ip] = f } f.n++ if f.n >= adminMaxFails { f.until = now.Add(adminLockout) } } func (a *adminAuth) clear(ip string) { a.mu.Lock() defer a.mu.Unlock() delete(a.fails, ip) } // Option configures the API server. type Option func(*Server) // WithAdminPassword turns the admin interface on, guarded by this password. // An empty password leaves it off. func WithAdminPassword(pw string) Option { return func(s *Server) { if pw != "" { s.admin = newAdminAuth(pw) } } } func noStore(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store") } // adminOnly wraps a handler so it needs a valid admin session. func (s *Server) adminOnly(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { noStore(w) if s.admin == nil { httpError(w, http.StatusNotFound, "the admin interface is off: set HALCYON_ADMIN_PASSWORD to turn it on") return } c, err := r.Cookie(adminCookie) if err != nil || !s.admin.valid(c.Value, time.Now()) { httpError(w, http.StatusUnauthorized, "admin login required") return } h(w, r) }) } func secureRequest(r *http.Request) bool { return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) { noStore(w) if s.admin == nil { httpError(w, http.StatusNotFound, "the admin interface is off: set HALCYON_ADMIN_PASSWORD to turn it on") return } ip, now := clientIP(r), time.Now() if wait, locked := s.admin.lockedOut(ip, now); locked { w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) httpError(w, http.StatusTooManyRequests, "too many failed attempts: try again in "+wait.Round(time.Second).String()) return } var req struct { Password string `json:"password"` } body, ok := readBody(w, r, 4<<10) if !ok { return } if err := json.Unmarshal(body, &req); err != nil { httpError(w, http.StatusBadRequest, `body must be {"password": "..."}`) return } if !s.admin.passwordOK(req.Password) { s.admin.recordFailure(ip, now) log.Printf("admin: failed login from %s", ip) time.Sleep(400 * time.Millisecond) // slows guessing further httpError(w, http.StatusUnauthorized, "wrong password") return } s.admin.clear(ip) log.Printf("admin: login from %s", ip) http.SetCookie(w, &http.Cookie{ Name: adminCookie, Value: s.admin.token(now), Path: "/admin", MaxAge: int(adminSessionTTL.Seconds()), HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: secureRequest(r), }) writeJSON(w, http.StatusOK, map[string]any{"admin": true}) } func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) { noStore(w) http.SetCookie(w, &http.Cookie{Name: adminCookie, Value: "", Path: "/admin", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: secureRequest(r)}) w.WriteHeader(http.StatusNoContent) } func (s *Server) adminSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"admin": true, "actions": store.AuditActions}) } func (s *Server) adminAccounts(w http.ResponseWriter, r *http.Request) { rows, err := s.st.ListAccounts(r.Context()) if err != nil { fail(w, err) return } type account struct { ID int64 `json:"id"` Name string `json:"name"` Credits int64 `json:"credits"` Ships int `json:"ships"` Active int `json:"in_belt"` Slots int `json:"slots_used"` Actions int `json:"actions"` LastLogin int64 `json:"last_login"` LastAction int64 `json:"last_action"` } out := make([]account, 0, len(rows)) for _, a := range rows { out = append(out, account{a.ID, a.Name, a.Credits, a.Ships, a.Active, a.Slots, a.Actions, a.LastLogin, a.LastAction}) } writeJSON(w, http.StatusOK, out) } func pathID(w http.ResponseWriter, r *http.Request, name string) (int64, bool) { id, err := strconv.ParseInt(r.PathValue(name), 10, 64) if err != nil || id < 1 { httpError(w, http.StatusBadRequest, "bad "+name) return 0, false } return id, true } func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok { return } p, err := s.st.PlayerByID(r.Context(), id) if err != nil { fail(w, err) return } ships, err := s.st.PlayerShips(r.Context(), id) if err != nil { fail(w, err) return } slots, err := s.st.ListSlotSummaries(r.Context(), id) if err != nil { fail(w, err) return } recent, err := s.st.ListAudit(r.Context(), store.AuditFilter{PlayerID: id, Limit: 25}) if err != nil { fail(w, err) return } type ship struct { ID int64 `json:"id"` Status string `json:"status"` ProgramBytes int `json:"program_bytes"` DownlinkDay int64 `json:"downlink_day"` } type slot struct { Slot int `json:"slot"` Name string `json:"name"` Size int `json:"size"` UpdatedAt string `json:"updated_at"` } resp := map[string]any{"id": p.ID, "name": p.Name, "credits": p.Credits, "ships": []ship{}, "slots": []slot{}, "audit": auditJSON(recent)} sh := []ship{} for _, x := range ships { sh = append(sh, ship{x.ID, x.Status, x.ProgramSize, x.DownlinkDay}) } sl := []slot{} for _, x := range slots { sl = append(sl, slot{x.Slot, x.Name, x.Size, x.UpdatedAt}) } resp["ships"], resp["slots"] = sh, sl writeJSON(w, http.StatusOK, resp) } // adminSlotSource shows the code saved in one of an account's slots (read only). func (s *Server) adminSlotSource(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok { return } n, err := strconv.Atoi(r.PathValue("n")) if err != nil || n < 1 { httpError(w, http.StatusBadRequest, errBadSlot.Error()) return } cs, err := s.st.GetCodeSlot(r.Context(), id, n) if errors.Is(err, store.ErrNotFound) { httpError(w, http.StatusNotFound, "that slot is empty") return } if err != nil { fail(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"slot": n, "name": cs.Name, "source": cs.Source}) } func auditJSON(rows []store.AuditRow) []map[string]any { out := make([]map[string]any, 0, len(rows)) for _, a := range rows { var detail any if json.Unmarshal([]byte(a.Detail), &detail) != nil { detail = a.Detail } out = append(out, map[string]any{ "id": a.ID, "at": a.At, "player_id": a.PlayerID, "name": a.Name, "action": a.Action, "ship": a.Ship, "slot": a.Slot, "detail": detail, "ip": a.IP, }) } return out } // adminAudit lists audit entries, newest first: ?player=&action=&before=&limit=. func (s *Server) adminAudit(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() f := store.AuditFilter{Action: strings.ToLower(q.Get("action"))} if !store.ValidAuditAction(f.Action) { httpError(w, http.StatusBadRequest, "unknown action") return } for name, dst := range map[string]*int64{"player": &f.PlayerID, "before": &f.Before} { if v := q.Get(name); v != "" { n, err := strconv.ParseInt(v, 10, 64) if err != nil || n < 0 { httpError(w, http.StatusBadRequest, "bad "+name) return } *dst = n } } if v := q.Get("limit"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 { httpError(w, http.StatusBadRequest, "bad limit") return } f.Limit = n } rows, err := s.st.ListAudit(r.Context(), f) if err != nil { fail(w, err) return } writeJSON(w, http.StatusOK, auditJSON(rows)) }