Files
Halcyon/api/admin.go
T
2026-10-01 22:16:48 +02:00

362 lines
9.8 KiB
Go

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 "<expiry unix seconds>.<hmac>".
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=<id>&action=<name>&before=<entry id>&limit=<n>.
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))
}