362 lines
9.8 KiB
Go
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))
|
|
}
|