vibes
This commit is contained in:
+361
@@ -0,0 +1,361 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminSessionTokens(t *testing.T) {
|
||||
a := newAdminAuth("correct horse battery")
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tok := a.token(now)
|
||||
|
||||
if !a.valid(tok, now) || !a.valid(tok, now.Add(adminSessionTTL-time.Second)) {
|
||||
t.Fatal("a fresh token should be valid for its whole lifetime")
|
||||
}
|
||||
if a.valid(tok, now.Add(adminSessionTTL)) || a.valid(tok, now.Add(24*time.Hour)) {
|
||||
t.Fatal("an expired token was accepted")
|
||||
}
|
||||
exp, mac, _ := strings.Cut(tok, ".")
|
||||
for name, bad := range map[string]string{
|
||||
"empty": "",
|
||||
"no separator": "12345",
|
||||
"wrong signature": exp + "." + strings.Repeat("0", len(mac)),
|
||||
"extended expiry": "9999999999." + mac, // keep the signature, lengthen the life
|
||||
"not a number": "abc." + a.sign("abc"),
|
||||
"signature missing": exp + ".",
|
||||
} {
|
||||
if a.valid(bad, now) {
|
||||
t.Errorf("%s: forged token accepted", name)
|
||||
}
|
||||
}
|
||||
// Another password derives another key: tokens do not carry over.
|
||||
if newAdminAuth("a different password").valid(tok, now) {
|
||||
t.Fatal("a token signed under one password was accepted under another")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordCheck(t *testing.T) {
|
||||
a := newAdminAuth("correct horse battery")
|
||||
if !a.passwordOK("correct horse battery") {
|
||||
t.Fatal("the right password was refused")
|
||||
}
|
||||
for _, pw := range []string{"", "correct horse batter", "correct horse battery ", "CORRECT HORSE BATTERY"} {
|
||||
if a.passwordOK(pw) {
|
||||
t.Errorf("%q accepted", pw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLockoutWindow(t *testing.T) {
|
||||
a := newAdminAuth("pw")
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
for i := 0; i < adminMaxFails-1; i++ {
|
||||
a.recordFailure("1.2.3.4", now)
|
||||
if _, locked := a.lockedOut("1.2.3.4", now); locked {
|
||||
t.Fatalf("locked out after only %d failures", i+1)
|
||||
}
|
||||
}
|
||||
a.recordFailure("1.2.3.4", now)
|
||||
wait, locked := a.lockedOut("1.2.3.4", now)
|
||||
if !locked || wait != adminLockout {
|
||||
t.Fatalf("after %d failures: locked=%v wait=%v", adminMaxFails, locked, wait)
|
||||
}
|
||||
if _, locked := a.lockedOut("5.6.7.8", now); locked {
|
||||
t.Fatal("one address's failures locked out another")
|
||||
}
|
||||
if _, locked := a.lockedOut("1.2.3.4", now.Add(adminLockout+time.Second)); locked {
|
||||
t.Fatal("the lockout never ends")
|
||||
}
|
||||
// Failures that are spread out over more than the window do not add up.
|
||||
b := newAdminAuth("pw")
|
||||
for i := 0; i < adminMaxFails+3; i++ {
|
||||
b.recordFailure("9.9.9.9", now.Add(time.Duration(i)*adminLockout))
|
||||
}
|
||||
if _, locked := b.lockedOut("9.9.9.9", now.Add(time.Duration(adminMaxFails+3)*adminLockout)); locked {
|
||||
t.Fatal("slow guessing should not trip the lockout")
|
||||
}
|
||||
a.clear("1.2.3.4")
|
||||
if _, locked := a.lockedOut("1.2.3.4", now); locked {
|
||||
t.Fatal("a successful login should clear the failures")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"halcyon/api"
|
||||
"halcyon/runner"
|
||||
"halcyon/sim"
|
||||
"halcyon/world"
|
||||
)
|
||||
|
||||
type mapData struct {
|
||||
Day int64 `json:"day"`
|
||||
Time int64 `json:"time"`
|
||||
Star struct{ Radius float64 } `json:"star"`
|
||||
Belt struct{ Inner, Outer float64 } `json:"belt"`
|
||||
Ranges struct{ Mining, Docking, Container float64 } `json:"ranges"`
|
||||
Station struct {
|
||||
X, Y, Z, VX, VY, VZ float64
|
||||
OrbitRadius float64 `json:"orbit_radius"`
|
||||
} `json:"station"`
|
||||
Ores []string `json:"ores"`
|
||||
Asteroids []struct {
|
||||
ID int64
|
||||
X, Y, Z float64
|
||||
Ore []int64
|
||||
Total int64
|
||||
OrbitRadius float64 `json:"orbit_radius"`
|
||||
Period int64
|
||||
Inclination float64
|
||||
} `json:"asteroids"`
|
||||
Ships []struct {
|
||||
ID, Owner int64
|
||||
Name string
|
||||
Alive bool
|
||||
X, Y, Z, VX, VY, VZ float64
|
||||
Fuel float64
|
||||
Towing int `json:"containers_in_tow"`
|
||||
TowOre int64 `json:"ore_in_tow"`
|
||||
} `json:"ships"`
|
||||
Containers []struct {
|
||||
ID int64
|
||||
X, Y, Z float64
|
||||
Ore int64
|
||||
Expires int64 `json:"expires_at"`
|
||||
} `json:"containers"`
|
||||
}
|
||||
|
||||
func (a *admin) mapData() (mapData, []byte) {
|
||||
a.t.Helper()
|
||||
raw := a.get("/admin/api/map", 200)
|
||||
var m mapData
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
return m, raw
|
||||
}
|
||||
|
||||
func TestAdminMap(t *testing.T) {
|
||||
srv, cfg, st := newSiteStore(t, "", api.WithAdminPassword(adminPW))
|
||||
a := newAdmin(t, srv.URL)
|
||||
a.get("/admin/api/map", 401)
|
||||
a.login()
|
||||
|
||||
// Before the first run: the world as the seed will make it.
|
||||
m, _ := a.mapData()
|
||||
if m.Day != 0 || len(m.Asteroids) != cfg.AsteroidCount || len(m.Ships) != 0 || len(m.Containers) != 0 {
|
||||
t.Fatalf("initial map: day %d, %d asteroids, %d ships, %d containers", m.Day, len(m.Asteroids), len(m.Ships), len(m.Containers))
|
||||
}
|
||||
if m.Belt.Inner != cfg.BeltInner.Float64() || m.Belt.Outer != cfg.BeltOuter.Float64() || m.Star.Radius != sim.StarRadiusKm {
|
||||
t.Errorf("belt/star: %+v %+v", m.Belt, m.Star)
|
||||
}
|
||||
if m.Ranges.Mining != sim.MineRangeKm || m.Ranges.Docking != sim.DockRangeKm || m.Ranges.Container != sim.ContainerRangeKm {
|
||||
t.Errorf("ranges: %+v", m.Ranges)
|
||||
}
|
||||
if want := (m.Belt.Inner + m.Belt.Outer) / 2; math.Abs(m.Station.OrbitRadius-want) > 1 {
|
||||
t.Errorf("station orbit radius %v, want %v", m.Station.OrbitRadius, want)
|
||||
}
|
||||
if len(m.Ores) != int(world.NumOre) || m.Ores[0] != "iron" {
|
||||
t.Errorf("ores = %v", m.Ores)
|
||||
}
|
||||
|
||||
// Two players launch ships that visit the station's container service.
|
||||
ann, bob := newPlayer(t, srv.URL, "ann"), newPlayer(t, srv.URL, "bob")
|
||||
ann.do("PUT", "/slots/5", map[string]string{"name": "TOW", "source": "ldi r1, 2\n out 0x66, r1\n ldi r1, 1\n out 0x55, r1\n halt"}, 200)
|
||||
bob.do("PUT", "/slots/5", map[string]string{"name": "DROP", "source": "ldi r1, 3\n out 0x66, r1\n halt"}, 200)
|
||||
ann.do("POST", "/ships/"+itoa(ann.ship)+"/launch", map[string]int{"slot": 5}, 202)
|
||||
bob.do("POST", "/ships/"+itoa(bob.ship)+"/launch", map[string]int{"slot": 5}, 202)
|
||||
|
||||
runCfg := cfg
|
||||
runCfg.AsteroidCount = 20
|
||||
if _, err := runner.RunNextDay(context.Background(), st, runCfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, raw1 := a.mapData()
|
||||
if m.Day != 1 || m.Time != 86400 || len(m.Asteroids) != 20 || len(m.Ships) != 2 {
|
||||
t.Fatalf("after a day: day %d time %d, %d asteroids, %d ships", m.Day, m.Time, len(m.Asteroids), len(m.Ships))
|
||||
}
|
||||
|
||||
// Asteroids are placed from their orbits at the snapshot time.
|
||||
asts, _ := world.Generate(runCfg)
|
||||
for _, i := range []int{0, 7, 19} {
|
||||
want, _ := asts[i].Orbit.State(m.Time)
|
||||
got := m.Asteroids[i]
|
||||
if got.ID != asts[i].ID || math.Abs(got.X-want.X.Float64()) > 0.01 || math.Abs(got.Y-want.Y.Float64()) > 0.01 || math.Abs(got.Z-want.Z.Float64()) > 0.01 {
|
||||
t.Errorf("asteroid %d at (%v,%v,%v), orbit says (%v,%v,%v)", got.ID, got.X, got.Y, got.Z, want.X.Float64(), want.Y.Float64(), want.Z.Float64())
|
||||
}
|
||||
}
|
||||
if m.Asteroids[0].Total <= 0 || len(m.Asteroids[0].Ore) != int(world.NumOre) {
|
||||
t.Errorf("asteroid ore: %+v", m.Asteroids[0])
|
||||
}
|
||||
|
||||
// Ships have position and velocity: a ship launched at the station rides its circular orbit.
|
||||
byName := map[string]int{}
|
||||
for i, s := range m.Ships {
|
||||
byName[s.Name] = i
|
||||
r := math.Hypot(s.X, s.Y)
|
||||
speed := math.Sqrt(s.VX*s.VX + s.VY*s.VY + s.VZ*s.VZ)
|
||||
if !s.Alive || math.Abs(r-m.Station.OrbitRadius)/m.Station.OrbitRadius > 0.01 {
|
||||
t.Errorf("ship %s: alive=%v at radius %.0f km, want about %.0f", s.Name, s.Alive, r, m.Station.OrbitRadius)
|
||||
}
|
||||
if math.Abs(speed-3)/3 > 0.02 {
|
||||
t.Errorf("ship %s speed %.4f km/s, want the 3 km/s orbit speed", s.Name, speed)
|
||||
}
|
||||
if cos := (s.X*s.VX + s.Y*s.VY) / (r * speed); math.Abs(cos) > 0.03 {
|
||||
t.Errorf("ship %s velocity is not tangential to its orbit (cos %.3f)", s.Name, cos)
|
||||
}
|
||||
if s.Fuel != float64(sim.CommandShip.FuelCap) {
|
||||
t.Errorf("ship %s fuel %v", s.Name, s.Fuel)
|
||||
}
|
||||
}
|
||||
if len(byName) != 2 {
|
||||
t.Fatalf("ships named %v", byName)
|
||||
}
|
||||
ai, bi := byName["ann"], byName["bob"]
|
||||
if m.Ships[ai].ID != ann.ship || m.Ships[ai].Towing != 2 || m.Ships[bi].Towing != 0 {
|
||||
t.Errorf("containers in tow: ann %d, bob %d (want 2 and 0)", m.Ships[ai].Towing, m.Ships[bi].Towing)
|
||||
}
|
||||
if len(m.Containers) != 3 {
|
||||
t.Fatalf("%d drifting containers, want bob's 3 (ann's 2 are in tow)", len(m.Containers))
|
||||
}
|
||||
for _, c := range m.Containers { // delivered on the first turn of day 0, adrift for ten days from then
|
||||
if c.Expires != int64(sim.ContainerLifeDays)*86400 || c.Ore != 0 {
|
||||
t.Errorf("drifting container %+v: want empty and expiring at %d", c, int64(sim.ContainerLifeDays)*86400)
|
||||
}
|
||||
}
|
||||
|
||||
// The response is cached per day, and changes when the world does.
|
||||
if _, raw2 := a.mapData(); string(raw1) != string(raw2) {
|
||||
t.Error("two requests on the same day differ")
|
||||
}
|
||||
if _, err := runner.RunNextDay(context.Background(), st, runCfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2, raw3 := a.mapData()
|
||||
if m2.Day != 2 || string(raw3) == string(raw1) {
|
||||
t.Errorf("after another day: day %d, cache stale=%v", m2.Day, string(raw3) == string(raw1))
|
||||
}
|
||||
if m2.Asteroids[0].X == m.Asteroids[0].X && m2.Asteroids[0].Y == m.Asteroids[0].Y {
|
||||
t.Error("asteroids did not move between days")
|
||||
}
|
||||
}
|
||||
|
||||
type orbitData struct {
|
||||
Ship int64
|
||||
Points [][3]float64
|
||||
Closed bool
|
||||
HitStar bool `json:"hit_star"`
|
||||
}
|
||||
|
||||
func (a *admin) orbit(ship int64, want int) orbitData {
|
||||
a.t.Helper()
|
||||
raw := a.get("/admin/api/map/orbit?ship="+itoa(ship), want)
|
||||
if want != 200 {
|
||||
return orbitData{}
|
||||
}
|
||||
var o orbitData
|
||||
if err := json.Unmarshal(raw, &o); err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func TestAdminMapOrbit(t *testing.T) {
|
||||
srv, cfg, st := newSiteStore(t, "", api.WithAdminPassword(adminPW))
|
||||
a := newAdmin(t, srv.URL)
|
||||
a.login()
|
||||
a.orbit(1, 404) // nothing launched yet
|
||||
|
||||
// A ship launched at the station, never thrusting, rides the station's own
|
||||
// circular orbit: the predicted path should close after about one turn and
|
||||
// stay at (about) the station's radius throughout.
|
||||
ann := newPlayer(t, srv.URL, "ann")
|
||||
ann.do("PUT", "/slots/5", map[string]string{"name": "COAST", "source": "halt"}, 200)
|
||||
ann.do("POST", "/ships/"+itoa(ann.ship)+"/launch", map[string]int{"slot": 5}, 202)
|
||||
runCfg := cfg
|
||||
runCfg.AsteroidCount = 20
|
||||
if _, err := runner.RunNextDay(context.Background(), st, runCfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := a.orbit(ann.ship, 200)
|
||||
if o.Ship != ann.ship || !o.Closed || o.HitStar {
|
||||
t.Fatalf("coasting orbit: closed=%v hit_star=%v", o.Closed, o.HitStar)
|
||||
}
|
||||
if len(o.Points) < 20 {
|
||||
t.Fatalf("only %d points", len(o.Points))
|
||||
}
|
||||
stationR := (cfg.BeltInner.Float64() + cfg.BeltOuter.Float64()) / 2
|
||||
for i, p := range o.Points {
|
||||
r := math.Sqrt(p[0]*p[0] + p[1]*p[1] + p[2]*p[2])
|
||||
if math.Abs(r-stationR)/stationR > 0.02 {
|
||||
t.Fatalf("point %d at radius %.0f, want about %.0f", i, r, stationR)
|
||||
}
|
||||
}
|
||||
// Roughly circular: the start and end points should be close together.
|
||||
first, last := o.Points[0], o.Points[len(o.Points)-1]
|
||||
if d := math.Hypot(first[0]-last[0], first[1]-last[1]); d > stationR*0.1 {
|
||||
t.Errorf("closed loop does not return near its start: gap %.0f km", d)
|
||||
}
|
||||
|
||||
a.orbit(9999, 404) // no such ship
|
||||
a.get("/admin/api/map/orbit", 400)
|
||||
a.get("/admin/api/map/orbit?ship=x", 400)
|
||||
if resp, _ := a.req("POST", "/admin/api/logout", nil, nil); resp.StatusCode != 204 {
|
||||
t.Fatalf("logout: %d", resp.StatusCode)
|
||||
}
|
||||
a.get("/admin/api/map/orbit?ship="+itoa(ann.ship), 401)
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"halcyon/api"
|
||||
)
|
||||
|
||||
const adminPW = "correct horse battery"
|
||||
|
||||
// admin is a browser-like client (it keeps the session cookie) for the admin API.
|
||||
type admin struct {
|
||||
t *testing.T
|
||||
base string
|
||||
c *http.Client
|
||||
}
|
||||
|
||||
func newAdmin(t *testing.T, base string) *admin {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &admin{t: t, base: base, c: &http.Client{Jar: jar}}
|
||||
}
|
||||
|
||||
func (a *admin) req(method, path string, body any, headers map[string]string) (*http.Response, []byte) {
|
||||
a.t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, a.base+path, rdr)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := a.c.Do(req)
|
||||
if err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
|
||||
func (a *admin) get(path string, want int) []byte {
|
||||
a.t.Helper()
|
||||
resp, b := a.req("GET", path, nil, nil)
|
||||
if resp.StatusCode != want {
|
||||
a.t.Fatalf("GET %s: status %d want %d: %s", path, resp.StatusCode, want, b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (a *admin) login() { a.t.Helper(); a.mustLogin(adminPW) }
|
||||
|
||||
func (a *admin) mustLogin(pw string) {
|
||||
a.t.Helper()
|
||||
resp, b := a.req("POST", "/admin/api/login", map[string]string{"password": pw}, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
a.t.Fatalf("admin login: %d %s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
type auditRow struct {
|
||||
ID int64 `json:"id"`
|
||||
At int64 `json:"at"`
|
||||
PlayerID int64 `json:"player_id"`
|
||||
Name string `json:"name"`
|
||||
Action string `json:"action"`
|
||||
Ship int64 `json:"ship"`
|
||||
Slot int `json:"slot"`
|
||||
Detail map[string]any `json:"detail"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
func (a *admin) audit(query string) []auditRow {
|
||||
a.t.Helper()
|
||||
var rows []auditRow
|
||||
if err := json.Unmarshal(a.get("/admin/api/audit"+query, 200), &rows); err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func actionsOf(rows []auditRow) []string {
|
||||
var out []string
|
||||
for i := len(rows) - 1; i >= 0; i-- { // oldest first
|
||||
out = append(out, rows[i].Action)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAdminIsOffWithoutAPassword(t *testing.T) {
|
||||
srv, _ := newSite(t, "")
|
||||
a := newAdmin(t, srv.URL)
|
||||
resp, b := a.req("POST", "/admin/api/login", map[string]string{"password": ""}, nil)
|
||||
if resp.StatusCode != 404 || !strings.Contains(string(b), "HALCYON_ADMIN_PASSWORD") {
|
||||
t.Fatalf("login with the interface off: %d %s", resp.StatusCode, b)
|
||||
}
|
||||
a.get("/admin/api/accounts", 404)
|
||||
a.get("/admin/api/audit", 404)
|
||||
}
|
||||
|
||||
func TestAdminLoginAndSession(t *testing.T) {
|
||||
srv, _ := newSite(t, "", api.WithAdminPassword(adminPW))
|
||||
a := newAdmin(t, srv.URL)
|
||||
|
||||
a.get("/admin/api/session", 401)
|
||||
a.get("/admin/api/accounts", 401)
|
||||
a.get("/admin/api/audit", 401)
|
||||
|
||||
if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": "wrong"}, nil); resp.StatusCode != 401 {
|
||||
t.Fatalf("wrong password: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := a.req("POST", "/admin/api/login", "not json", nil); resp.StatusCode != 400 {
|
||||
t.Fatalf("malformed body: %d", resp.StatusCode)
|
||||
}
|
||||
a.get("/admin/api/session", 401) // failed attempts do not log anyone in
|
||||
|
||||
resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("login: %d", resp.StatusCode)
|
||||
}
|
||||
var cookie *http.Cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "halcyon_admin" {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/admin" || cookie.MaxAge <= 0 {
|
||||
t.Fatalf("session cookie = %+v: it must be HttpOnly, SameSite=Strict, scoped to /admin and expire", cookie)
|
||||
}
|
||||
if cookie.Secure {
|
||||
t.Error("a plain-HTTP request should not get a Secure cookie (the browser would drop it)")
|
||||
}
|
||||
a.get("/admin/api/session", 200)
|
||||
a.get("/admin/api/accounts", 200)
|
||||
|
||||
// Behind the TLS-terminating proxy the cookie is Secure.
|
||||
b := newAdmin(t, srv.URL)
|
||||
resp, _ = b.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, map[string]string{"X-Forwarded-Proto": "https"})
|
||||
if c := resp.Cookies(); len(c) == 0 || !c[0].Secure {
|
||||
t.Errorf("cookie behind https = %+v, want Secure", c)
|
||||
}
|
||||
|
||||
// Every admin response is uncacheable.
|
||||
if resp, _ := a.req("GET", "/admin/api/accounts", nil, nil); resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("Cache-Control = %q", resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
// A tampered cookie is refused.
|
||||
tampered := newAdmin(t, srv.URL)
|
||||
req, _ := http.NewRequest("GET", srv.URL+"/admin/api/accounts", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "halcyon_admin", Value: "9999999999." + strings.Repeat("a", 64)})
|
||||
if resp, err := tampered.c.Do(req); err != nil || resp.StatusCode != 401 {
|
||||
t.Fatalf("forged cookie: %v %v", resp, err)
|
||||
}
|
||||
|
||||
// Logout ends the session.
|
||||
if resp, _ := a.req("POST", "/admin/api/logout", nil, nil); resp.StatusCode != 204 {
|
||||
t.Fatalf("logout: %d", resp.StatusCode)
|
||||
}
|
||||
a.get("/admin/api/session", 401)
|
||||
}
|
||||
|
||||
func TestAdminLoginIsRateLimitedPerAddress(t *testing.T) {
|
||||
srv, _ := newSite(t, "", api.WithAdminPassword(adminPW))
|
||||
a := newAdmin(t, srv.URL)
|
||||
// Behind the proxy the client's address is X-Forwarded-For; the test server's own peer is loopback.
|
||||
from := func(ip string) map[string]string { return map[string]string{"X-Forwarded-For": ip} }
|
||||
for i := 0; i < 5; i++ {
|
||||
if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": "guess"}, from("203.0.113.9")); resp.StatusCode != 401 {
|
||||
t.Fatalf("attempt %d: %d", i+1, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
// Now even the right password is refused for that address...
|
||||
resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, from("203.0.113.9"))
|
||||
if resp.StatusCode != 429 || resp.Header.Get("Retry-After") == "" {
|
||||
t.Fatalf("locked out address: %d retry-after=%q", resp.StatusCode, resp.Header.Get("Retry-After"))
|
||||
}
|
||||
// ...but not for anyone else.
|
||||
if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, from("198.51.100.7")); resp.StatusCode != 200 {
|
||||
t.Fatalf("a different address was locked out too: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAndPlayerCredentialsAreSeparate(t *testing.T) {
|
||||
srv, _ := newSite(t, "", api.WithAdminPassword(adminPW))
|
||||
p := newPlayer(t, srv.URL, "ann")
|
||||
a := newAdmin(t, srv.URL)
|
||||
a.login()
|
||||
|
||||
// A player's API key opens nothing under /admin, and the admin session opens nothing of a player's.
|
||||
resp, _ := http.DefaultClient.Do(mustReq("GET", srv.URL+"/admin/api/accounts", "Bearer "+p.key))
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("a player key on the admin API: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := a.req("GET", "/ships", nil, nil); resp.StatusCode != 401 {
|
||||
t.Fatalf("the admin cookie on a player endpoint: %d", resp.StatusCode)
|
||||
}
|
||||
// The admin password is not an API key.
|
||||
resp, _ = http.DefaultClient.Do(mustReq("GET", srv.URL+"/me", "Bearer "+adminPW))
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("the admin password as an API key: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReq(method, url, auth string) *http.Request {
|
||||
r, _ := http.NewRequest(method, url, nil)
|
||||
r.Header.Set("Authorization", auth)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAuditTrailOfPlayerActions(t *testing.T) {
|
||||
srv, cfg := newSite(t, "", api.WithAdminPassword(adminPW))
|
||||
a := newAdmin(t, srv.URL)
|
||||
a.login()
|
||||
if rows := a.audit(""); len(rows) != 0 {
|
||||
t.Fatalf("a new world has %d audit entries", len(rows))
|
||||
}
|
||||
|
||||
p := newPlayer(t, srv.URL, "ann") // register
|
||||
post := func(path string, body any, want int, hdr map[string]string) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest("POST", srv.URL+path, rdr)
|
||||
req.Header.Set("Authorization", "Bearer "+p.key)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("POST %s: %d, want %d", path, resp.StatusCode, want)
|
||||
}
|
||||
}
|
||||
post("/login", nil, 200, map[string]string{"User-Agent": "TestBrowser/1.0", "X-Forwarded-For": "203.0.113.55, 10.0.0.1"})
|
||||
src := "ldi r1, 5\nhalt"
|
||||
p.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": src}, 200)
|
||||
p.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200)
|
||||
p.do("DELETE", "/slots/5", nil, 204)
|
||||
p.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": src}, 200)
|
||||
post("/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 6}, 202, nil)
|
||||
msg := []byte{0x48, 0x45, 0x4c, 0x4c, 0x4f, 0x00, 0xff}
|
||||
req, _ := http.NewRequest("PUT", srv.URL+"/ships/"+itoa(p.ship)+"/uplink", bytes.NewReader(msg))
|
||||
req.Header.Set("Authorization", "Bearer "+p.key)
|
||||
if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 204 {
|
||||
t.Fatalf("uplink: %v %v", resp, err)
|
||||
}
|
||||
|
||||
rows := a.audit("")
|
||||
want := []string{"register", "login", "code_save", "code_save", "code_clear", "code_save", "launch", "uplink"}
|
||||
if got := actionsOf(rows); strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("audit trail = %v\nwant %v", got, want)
|
||||
}
|
||||
if rows[0].Action != "uplink" || rows[len(rows)-1].Action != "register" {
|
||||
t.Fatal("entries should be listed newest first")
|
||||
}
|
||||
byAction := map[string][]auditRow{}
|
||||
for i := len(rows) - 1; i >= 0; i-- {
|
||||
byAction[rows[i].Action] = append(byAction[rows[i].Action], rows[i])
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Name != "ann" || r.PlayerID != 1 || r.At == 0 || r.IP == "" {
|
||||
t.Errorf("entry %d lacks who/when/where: %+v", r.ID, r)
|
||||
}
|
||||
}
|
||||
if d := byAction["register"][0].Detail; d["name"] != "ann" || byAction["register"][0].Ship != p.ship {
|
||||
t.Errorf("register: %+v", byAction["register"][0])
|
||||
}
|
||||
lg := byAction["login"][0]
|
||||
if lg.IP != "203.0.113.55" || lg.Detail["user_agent"] != "TestBrowser/1.0" {
|
||||
t.Errorf("login: ip=%q detail=%v (the client address behind the proxy, and its browser)", lg.IP, lg.Detail)
|
||||
}
|
||||
s1 := byAction["code_save"][0]
|
||||
if s1.Slot != 5 || s1.Detail["name"] != "MINER" || s1.Detail["bytes"] != float64(len(src)) || s1.Detail["sha256"] != sha256hex(src) {
|
||||
t.Errorf("first save: %+v", s1)
|
||||
}
|
||||
if s2 := byAction["code_save"][1]; s2.Slot != 5 || s2.Detail["name"] != "MINER 2" {
|
||||
t.Errorf("second save: %+v", s2)
|
||||
}
|
||||
if c := byAction["code_clear"][0]; c.Slot != 5 {
|
||||
t.Errorf("clear: %+v", c)
|
||||
}
|
||||
l := byAction["launch"][0]
|
||||
if l.Ship != p.ship || l.Slot != 6 || l.Detail["program_bytes"] != float64(8) || l.Detail["slot_name"] != "TINY" {
|
||||
t.Errorf("launch: %+v", l)
|
||||
}
|
||||
u := byAction["uplink"][0]
|
||||
if u.Ship != p.ship || u.Detail["bytes"] != float64(len(msg)) || u.Detail["hex"] != hex.EncodeToString(msg) {
|
||||
t.Errorf("uplink: %+v (the data itself must be recorded)", u)
|
||||
}
|
||||
|
||||
// Filters and paging.
|
||||
if got := actionsOf(a.audit("?action=code_save")); len(got) != 3 {
|
||||
t.Errorf("action filter returned %v", got)
|
||||
}
|
||||
if got := a.audit("?action=CODE_SAVE"); len(got) != 3 {
|
||||
t.Errorf("action filters ignore case: got %d", len(got))
|
||||
}
|
||||
if got := a.audit("?player=1"); len(got) != len(want) {
|
||||
t.Errorf("player filter: %d", len(got))
|
||||
}
|
||||
if got := a.audit("?player=99"); len(got) != 0 {
|
||||
t.Errorf("unknown player: %d entries", len(got))
|
||||
}
|
||||
var seen []int64
|
||||
before := ""
|
||||
for i := 0; i < 10; i++ {
|
||||
page := a.audit("?limit=3" + before)
|
||||
if len(page) == 0 {
|
||||
break
|
||||
}
|
||||
for _, r := range page {
|
||||
seen = append(seen, r.ID)
|
||||
}
|
||||
before = "&before=" + itoa(page[len(page)-1].ID)
|
||||
}
|
||||
if len(seen) != len(want) {
|
||||
t.Fatalf("paging returned %d entries %v, want %d", len(seen), seen, len(want))
|
||||
}
|
||||
for i := 1; i < len(seen); i++ {
|
||||
if seen[i] >= seen[i-1] {
|
||||
t.Fatalf("paging repeated or reordered entries: %v", seen)
|
||||
}
|
||||
}
|
||||
for _, q := range []string{"?action=bogus", "?player=abc", "?before=-1", "?limit=0", "?limit=x"} {
|
||||
a.get("/admin/api/audit"+q, 400)
|
||||
}
|
||||
|
||||
// Things that failed are not actions taken, and are not logged.
|
||||
n := len(a.audit(""))
|
||||
p.do("PUT", "/slots/1", map[string]string{"name": "X", "source": "halt"}, 403) // read-only example
|
||||
p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 6}, 409) // already launching
|
||||
p.do("PUT", "/ships/"+itoa(p.ship)+"/program", string(make([]byte, cfg.ProgramBytes+4)), 413) // too large
|
||||
bad := &player{t: t, base: srv.URL, key: "not-a-key"}
|
||||
bad.do("POST", "/login", nil, 401) // a wrong key is not a login
|
||||
if got := len(a.audit("")); got != n {
|
||||
t.Errorf("%d entries recorded for actions that failed", got-n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRecordsProgramUploadAndPlainLaunch(t *testing.T) {
|
||||
srv, _ := newSite(t, "", api.WithAdminPassword(adminPW))
|
||||
a := newAdmin(t, srv.URL)
|
||||
a.login()
|
||||
p := newPlayer(t, srv.URL, "bob")
|
||||
prog := []byte{0x02, 0, 0, 0} // HALT
|
||||
req, _ := http.NewRequest("PUT", srv.URL+"/ships/"+itoa(p.ship)+"/program", bytes.NewReader(prog))
|
||||
req.Header.Set("Authorization", "Bearer "+p.key)
|
||||
if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 204 {
|
||||
t.Fatalf("upload: %v %v", resp, err)
|
||||
}
|
||||
p.do("POST", "/ships/"+itoa(p.ship)+"/launch", nil, 202)
|
||||
rows := a.audit("")
|
||||
if got := strings.Join(actionsOf(rows), ","); got != "register,program_upload,launch" {
|
||||
t.Fatalf("trail = %s", got)
|
||||
}
|
||||
if up := rows[1]; up.Detail["bytes"] != float64(4) || up.Detail["sha256"] != sha256hex(string(prog)) || up.Ship != p.ship {
|
||||
t.Errorf("program upload: %+v", up)
|
||||
}
|
||||
if l := rows[0]; l.Slot != 0 || l.Ship != p.ship || l.Detail["program"] == nil {
|
||||
t.Errorf("plain launch: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccountsAndDetail(t *testing.T) {
|
||||
srv, _ := newSite(t, "", api.WithAdminPassword(adminPW))
|
||||
a := newAdmin(t, srv.URL)
|
||||
a.login()
|
||||
|
||||
ann := newPlayer(t, srv.URL, "ann")
|
||||
bob := newPlayer(t, srv.URL, "<img src=x onerror=alert(1)>") // hostile names must come back as plain data
|
||||
req, _ := http.NewRequest("POST", srv.URL+"/login", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+ann.key)
|
||||
if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("login: %v %v", resp, err)
|
||||
}
|
||||
ann.do("PUT", "/slots/4", map[string]string{"name": "ALPHA", "source": "; alpha\nhalt"}, 200)
|
||||
ann.do("PUT", "/slots/9", map[string]string{"name": "OMEGA", "source": "; omega\nhalt"}, 200)
|
||||
ann.do("POST", "/ships/"+itoa(ann.ship)+"/launch", map[string]int{"slot": 4}, 202)
|
||||
|
||||
var accts []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Credits int64 `json:"credits"`
|
||||
Ships int `json:"ships"`
|
||||
SlotsUsed int `json:"slots_used"`
|
||||
Actions int `json:"actions"`
|
||||
LastLogin int64 `json:"last_login"`
|
||||
LastAction int64 `json:"last_action"`
|
||||
}
|
||||
if err := json.Unmarshal(a.get("/admin/api/accounts", 200), &accts); err != nil || len(accts) != 2 {
|
||||
t.Fatalf("accounts: %v %v", err, accts)
|
||||
}
|
||||
if x := accts[0]; x.ID != 1 || x.Name != "ann" || x.Ships != 1 || x.SlotsUsed != 2 || x.Actions != 5 || x.LastLogin == 0 || x.LastAction == 0 {
|
||||
t.Errorf("ann's row: %+v", x)
|
||||
}
|
||||
if x := accts[1]; x.Name != "<img src=x onerror=alert(1)>" || x.Actions != 1 || x.LastLogin != 0 || x.SlotsUsed != 0 {
|
||||
t.Errorf("bob's row: %+v", x)
|
||||
}
|
||||
_ = bob
|
||||
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Ships []struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ProgramBytes int `json:"program_bytes"`
|
||||
} `json:"ships"`
|
||||
Slots []struct {
|
||||
Slot int `json:"slot"`
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
} `json:"slots"`
|
||||
Audit []auditRow `json:"audit"`
|
||||
}
|
||||
if err := json.Unmarshal(a.get("/admin/api/accounts/1", 200), &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Name != "ann" || len(d.Ships) != 1 || d.Ships[0].Status != "launching" || d.Ships[0].ProgramBytes == 0 {
|
||||
t.Errorf("detail ships: %+v", d)
|
||||
}
|
||||
if len(d.Slots) != 2 || d.Slots[0].Slot != 4 || d.Slots[0].Name != "ALPHA" || d.Slots[1].Slot != 9 || d.Slots[0].Size != len("; alpha\nhalt") {
|
||||
t.Errorf("detail slots: %+v", d.Slots)
|
||||
}
|
||||
if len(d.Audit) != 5 || d.Audit[0].Action != "launch" {
|
||||
t.Errorf("detail audit: %d entries, first %v", len(d.Audit), d.Audit)
|
||||
}
|
||||
// Another account's actions do not leak into this one's.
|
||||
for _, r := range d.Audit {
|
||||
if r.PlayerID != 1 {
|
||||
t.Errorf("foreign entry in ann's audit: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// The code saved in a slot can be read.
|
||||
var src struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
json.Unmarshal(a.get("/admin/api/accounts/1/slots/9", 200), &src)
|
||||
if src.Name != "OMEGA" || src.Source != "; omega\nhalt" {
|
||||
t.Errorf("slot source: %+v", src)
|
||||
}
|
||||
a.get("/admin/api/accounts/1/slots/12", 404) // empty
|
||||
a.get("/admin/api/accounts/1/slots/x", 400)
|
||||
a.get("/admin/api/accounts/99", 404)
|
||||
a.get("/admin/api/accounts/abc", 400)
|
||||
a.get("/admin/api/accounts/99/slots/4", 404)
|
||||
}
|
||||
|
||||
func sha256hex(s string) string {
|
||||
return sha256HexOf([]byte(s))
|
||||
}
|
||||
|
||||
func sha256HexOf(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
"halcyon/runner"
|
||||
"halcyon/sim"
|
||||
"halcyon/world"
|
||||
)
|
||||
|
||||
// The admin map shows the world as it stood when the last daily run ended: every
|
||||
// asteroid (placed from its orbit at that moment), every launched ship with its
|
||||
// velocity, the station and any containers left drifting. Units are kilometres
|
||||
// and kilometres per second, in the ecliptic frame centred on the Star.
|
||||
|
||||
// mapCache holds the last response, valid while the world stays on the same day.
|
||||
type mapCache struct {
|
||||
mu sync.Mutex
|
||||
day string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (s *Server) adminMap(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
day, _, err := s.st.GetMeta(ctx, runner.MetaDay)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.mapCache.mu.Lock()
|
||||
defer s.mapCache.mu.Unlock()
|
||||
if s.mapCache.body == nil || s.mapCache.day != day {
|
||||
body, err := s.buildMap(r)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.mapCache.day, s.mapCache.body = day, body
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(s.mapCache.body)
|
||||
}
|
||||
|
||||
func (s *Server) buildMap(r *http.Request) ([]byte, error) {
|
||||
st, err := s.st.LoadState(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st == nil { // nothing has run yet: show the world as it will begin
|
||||
st = sim.NewState(s.cfg)
|
||||
}
|
||||
names := map[int64]string{}
|
||||
accounts, err := s.st.ListAccounts(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range accounts {
|
||||
names[a.ID] = a.Name
|
||||
}
|
||||
|
||||
t := st.Day * config.SecondsPerDay
|
||||
type asteroid struct {
|
||||
ID int64 `json:"id"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
Ore []int64 `json:"ore"`
|
||||
Total int64 `json:"total"`
|
||||
Radius float64 `json:"orbit_radius"`
|
||||
Period int64 `json:"period"`
|
||||
Inc float64 `json:"inclination"`
|
||||
}
|
||||
asts := make([]asteroid, 0, len(st.Asteroids))
|
||||
for i := range st.Asteroids {
|
||||
a := &st.Asteroids[i]
|
||||
p, _ := a.Orbit.State(t)
|
||||
ore := make([]int64, world.NumOre)
|
||||
copy(ore, a.Ore[:])
|
||||
asts = append(asts, asteroid{a.ID, p.X.Float64(), p.Y.Float64(), p.Z.Float64(), ore, a.TotalOre(), a.Orbit.A.Float64(), a.Orbit.Period, a.Orbit.Inc.Float64()})
|
||||
}
|
||||
|
||||
towCount := map[int64]int{}
|
||||
towOre := map[int64]int64{}
|
||||
type container struct {
|
||||
ID int64 `json:"id"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
Ore int64 `json:"ore"`
|
||||
Expires int64 `json:"expires_at"`
|
||||
}
|
||||
drifting := []container{}
|
||||
for _, c := range st.Containers {
|
||||
if c.Towed != 0 {
|
||||
towCount[c.Towed]++
|
||||
towOre[c.Towed] += c.OreTotal()
|
||||
continue
|
||||
}
|
||||
drifting = append(drifting, container{c.ID, c.Pos.X.Float64(), c.Pos.Y.Float64(), c.Pos.Z.Float64(), c.OreTotal(), c.ExpiresAt})
|
||||
}
|
||||
|
||||
type ship struct {
|
||||
ID int64 `json:"id"`
|
||||
Owner int64 `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Alive bool `json:"alive"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
VX float64 `json:"vx"`
|
||||
VY float64 `json:"vy"`
|
||||
VZ float64 `json:"vz"`
|
||||
Fuel float64 `json:"fuel"`
|
||||
Throttle int32 `json:"throttle"`
|
||||
Mining bool `json:"mining"`
|
||||
Earned int64 `json:"earned"`
|
||||
Towing int `json:"containers_in_tow"`
|
||||
TowOre int64 `json:"ore_in_tow"`
|
||||
}
|
||||
ships := make([]ship, 0, len(st.Ships))
|
||||
for _, sh := range st.Ships {
|
||||
ships = append(ships, ship{sh.ID, sh.Owner, names[sh.Owner], sh.Alive,
|
||||
sh.Pos.X.Float64(), sh.Pos.Y.Float64(), sh.Pos.Z.Float64(), sh.Vel.X.Float64(), sh.Vel.Y.Float64(), sh.Vel.Z.Float64(),
|
||||
sh.Fuel.Float64(), sh.Throttle, sh.Mining, sh.Earned, towCount[sh.ID], towOre[sh.ID]})
|
||||
}
|
||||
|
||||
sp, sv := st.Station.Orbit.State(t)
|
||||
oreNames := make([]string, world.NumOre)
|
||||
for o := range oreNames {
|
||||
oreNames[o] = world.Ore(o).String()
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"day": st.Day,
|
||||
"time": t,
|
||||
"star": map[string]any{"radius": sim.StarRadiusKm},
|
||||
"belt": map[string]any{"inner": s.cfg.BeltInner.Float64(), "outer": s.cfg.BeltOuter.Float64()},
|
||||
"ranges": map[string]any{"mining": sim.MineRangeKm, "docking": sim.DockRangeKm, "container": sim.ContainerRangeKm},
|
||||
"station": map[string]any{
|
||||
"x": sp.X.Float64(), "y": sp.Y.Float64(), "z": sp.Z.Float64(),
|
||||
"vx": sv.X.Float64(), "vy": sv.Y.Float64(), "vz": sv.Z.Float64(), "orbit_radius": st.Station.Orbit.A.Float64(),
|
||||
},
|
||||
"ores": oreNames,
|
||||
"asteroids": asts,
|
||||
"ships": ships,
|
||||
"containers": drifting,
|
||||
})
|
||||
}
|
||||
|
||||
// adminMapOrbit predicts the path a ship would coast along from its current
|
||||
// position and velocity, with the engine off from here on. It is what the map
|
||||
// draws when a ship is clicked.
|
||||
func (s *Server) adminMapOrbit(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := strconv.ParseInt(r.URL.Query().Get("ship"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpError(w, http.StatusBadRequest, "ship must be a positive integer")
|
||||
return
|
||||
}
|
||||
st, err := s.st.LoadState(ctx)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if st == nil {
|
||||
st = sim.NewState(s.cfg)
|
||||
}
|
||||
var sh *sim.Ship
|
||||
for _, x := range st.Ships {
|
||||
if x.ID == id {
|
||||
sh = x
|
||||
break
|
||||
}
|
||||
}
|
||||
if sh == nil || !sh.Alive {
|
||||
httpError(w, http.StatusNotFound, "no such ship in the belt")
|
||||
return
|
||||
}
|
||||
points, closed, hitStar := predictOrbit(s.cfg, sh.Pos, sh.Vel)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ship": id, "points": points, "closed": closed, "hit_star": hitStar})
|
||||
}
|
||||
|
||||
// predictOrbit integrates a ballistic (engine-off) path under the belt's
|
||||
// simplified gravity (a pull of OrbitSpeed^2/r toward the Star, the same as
|
||||
// sim.physics applies), starting from pos and vel. That pull is always
|
||||
// radial, so angular momentum -- and with it the orbital plane -- never
|
||||
// changes: "one turn around the Star" is therefore well defined even though,
|
||||
// unlike real gravity, this force does not in general trace a closed ellipse.
|
||||
// The path is sampled until it has swept a full turn in that plane, falls
|
||||
// into the Star, flies far outside the belt, or a time cap is reached
|
||||
// (whichever comes first), in plain float64: this is a display aid, not part
|
||||
// of the deterministic simulation.
|
||||
func predictOrbit(cfg config.Config, pos, vel fixed.Vec) (points [][3]float64, closed, hitStar bool) {
|
||||
px, py, pz := pos.X.Float64(), pos.Y.Float64(), pos.Z.Float64()
|
||||
vx, vy, vz := vel.X.Float64(), vel.Y.Float64(), vel.Z.Float64()
|
||||
orbitSpeed := cfg.OrbitSpeed.Float64()
|
||||
dt := float64(cfg.TickSeconds())
|
||||
r0 := math.Sqrt(px*px + py*py + pz*pz)
|
||||
starR := float64(sim.StarRadiusKm)
|
||||
if r0 < starR || orbitSpeed <= 0 {
|
||||
return nil, false, r0 < starR
|
||||
}
|
||||
|
||||
// Plane normal (direction of pos x vel, i.e. of angular momentum), unit length.
|
||||
nx, ny, nz := py*vz-pz*vy, pz*vx-px*vz, px*vy-py*vx
|
||||
nlen := math.Sqrt(nx*nx + ny*ny + nz*nz)
|
||||
planar := nlen > 1e-9
|
||||
if planar {
|
||||
nx, ny, nz = nx/nlen, ny/nlen, nz/nlen
|
||||
}
|
||||
|
||||
// A ship on a circular orbit at the belt's own speed takes tens of days to
|
||||
// come around, so the tick-by-tick budget has to be generous; the actual
|
||||
// game tick length is used as the step so this matches sim.physics exactly.
|
||||
const maxTicks = 300_000 // a hard safety cap (~208 sim days), for a wild escape
|
||||
const targetPoints = 400
|
||||
periodTicks := int(2 * math.Pi * r0 / orbitSpeed / dt)
|
||||
capTicks := periodTicks * 3
|
||||
if capTicks <= 0 || capTicks > maxTicks {
|
||||
capTicks = maxTicks
|
||||
}
|
||||
recordEvery := capTicks / targetPoints
|
||||
if recordEvery < 1 {
|
||||
recordEvery = 1
|
||||
}
|
||||
escapeR := math.Max(5*r0, 5*cfg.BeltOuter.Float64())
|
||||
|
||||
points = [][3]float64{{px, py, pz}}
|
||||
prevX, prevY, prevZ := px, py, pz
|
||||
swept := 0.0
|
||||
for i := 1; i <= capTicks; i++ {
|
||||
r := math.Sqrt(px*px + py*py + pz*pz)
|
||||
g := orbitSpeed * orbitSpeed * dt / r
|
||||
vx -= px / r * g
|
||||
vy -= py / r * g
|
||||
vz -= pz / r * g
|
||||
px += vx * dt
|
||||
py += vy * dt
|
||||
pz += vz * dt
|
||||
|
||||
if planar {
|
||||
cx, cy, cz := prevY*pz-prevZ*py, prevZ*px-prevX*pz, prevX*py-prevY*px
|
||||
sin := cx*nx + cy*ny + cz*nz
|
||||
cos := prevX*px + prevY*py + prevZ*pz
|
||||
swept += math.Atan2(sin, cos)
|
||||
}
|
||||
prevX, prevY, prevZ = px, py, pz
|
||||
|
||||
last := i == capTicks
|
||||
if i%recordEvery == 0 || last {
|
||||
points = append(points, [3]float64{px, py, pz})
|
||||
}
|
||||
if r := math.Sqrt(px*px + py*py + pz*pz); r < starR {
|
||||
points = append(points, [3]float64{px, py, pz})
|
||||
return points, false, true
|
||||
} else if r > escapeR {
|
||||
return points, false, false
|
||||
}
|
||||
if planar && math.Abs(swept) >= 2*math.Pi {
|
||||
return points, true, false
|
||||
}
|
||||
}
|
||||
return points, false, false
|
||||
}
|
||||
+95
-5
@@ -4,6 +4,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -20,8 +21,11 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
cfg config.Config
|
||||
st *store.Store
|
||||
cfg config.Config
|
||||
admin *adminAuth // nil when the admin interface is off
|
||||
|
||||
mapCache mapCache // the admin map's last response
|
||||
}
|
||||
|
||||
// New returns the HTTP handler.
|
||||
@@ -37,26 +41,44 @@ type Server struct {
|
||||
// slot's code is assembled and sealed in first)
|
||||
// GET /slots, GET/PUT/DELETE /slots/{n} the sixteen code slots (1-3 are read-only examples)
|
||||
// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink)
|
||||
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header
|
||||
// POST /ships/{id}/scrap scrap a ship in the belt (on the next run it vanishes and a new ship
|
||||
// appears in your inventory)
|
||||
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header; ?day=N picks an earlier one
|
||||
// GET /ships/{id}/downlinks {"days":[...]} the days with a stored downlink
|
||||
//
|
||||
// Authenticated routes take "Authorization: Bearer <api_key>".
|
||||
func New(st *store.Store, cfg config.Config) *http.ServeMux {
|
||||
func New(st *store.Store, cfg config.Config, opts ...Option) *http.ServeMux {
|
||||
s := &Server{st: st, cfg: cfg}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /register", s.register)
|
||||
mux.HandleFunc("GET /market", s.market)
|
||||
mux.HandleFunc("GET /info", s.info)
|
||||
mux.HandleFunc("POST /assemble", s.assemble)
|
||||
mux.Handle("POST /login", s.auth(s.playerLogin))
|
||||
mux.Handle("GET /me", s.auth(s.me))
|
||||
mux.Handle("GET /ships", s.auth(s.ships))
|
||||
mux.Handle("PUT /ships/{id}/program", s.auth(s.program))
|
||||
mux.Handle("POST /ships/{id}/launch", s.auth(s.launch))
|
||||
mux.Handle("POST /ships/{id}/scrap", s.auth(s.scrap))
|
||||
mux.HandleFunc("POST /admin/api/login", s.adminLogin)
|
||||
mux.HandleFunc("POST /admin/api/logout", s.adminLogout)
|
||||
mux.Handle("GET /admin/api/session", s.adminOnly(s.adminSession))
|
||||
mux.Handle("GET /admin/api/accounts", s.adminOnly(s.adminAccounts))
|
||||
mux.Handle("GET /admin/api/accounts/{id}", s.adminOnly(s.adminAccount))
|
||||
mux.Handle("GET /admin/api/accounts/{id}/slots/{n}", s.adminOnly(s.adminSlotSource))
|
||||
mux.Handle("GET /admin/api/audit", s.adminOnly(s.adminAudit))
|
||||
mux.Handle("GET /admin/api/map", s.adminOnly(s.adminMap))
|
||||
mux.Handle("GET /admin/api/map/orbit", s.adminOnly(s.adminMapOrbit)) // ?ship=N: its predicted coasting path
|
||||
mux.Handle("GET /slots", s.auth(s.listSlots))
|
||||
mux.Handle("GET /slots/{n}", s.auth(s.getSlot))
|
||||
mux.Handle("PUT /slots/{n}", s.auth(s.putSlot))
|
||||
mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot))
|
||||
mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink))
|
||||
mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink))
|
||||
mux.Handle("GET /ships/{id}/downlinks", s.auth(s.downlinks))
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -117,9 +139,25 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "register", ship, 0, map[string]any{"name": p.Name})
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship})
|
||||
}
|
||||
|
||||
// playerLogin is what the sign-in form calls once it has been given an access key:
|
||||
// it checks the key, returns the account, and puts the sign-in in the audit log.
|
||||
// (Every other request is authenticated by the key alone, so it is not a "login".)
|
||||
func (s *Server) playerLogin(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
s.audit(r, p.ID, "login", 0, 0, map[string]any{"user_agent": trunc(r.UserAgent(), 200)})
|
||||
s.me(w, r, p)
|
||||
}
|
||||
|
||||
func trunc(s string, n int) string {
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) currentDay(ctx context.Context) int64 {
|
||||
v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay)
|
||||
if !ok {
|
||||
@@ -240,6 +278,7 @@ func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "program_upload", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b)})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -271,6 +310,20 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "launch", id, 0, map[string]any{"program": "already sealed in the ship"})
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func (s *Server) scrap(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.st.Scrap(r.Context(), p.ID, id); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "scrap", id, 0, nil)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
@@ -291,15 +344,52 @@ func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
// The whole message is kept: it is at most one kilobyte.
|
||||
s.audit(r, p.ID, "uplink", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b), "hex": hex.EncodeToString(b)})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) downlinks(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, _, err := s.st.Downlink(r.Context(), p.ID, id); err != nil {
|
||||
fail(w, err) // not this player's ship
|
||||
return
|
||||
}
|
||||
days, err := s.st.DownlinkDays(r.Context(), p.ID, id)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"days": days})
|
||||
}
|
||||
|
||||
func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, day, err := s.st.Downlink(r.Context(), p.ID, id)
|
||||
var (
|
||||
data []byte
|
||||
day int64
|
||||
err error
|
||||
)
|
||||
if q := r.URL.Query().Get("day"); q != "" {
|
||||
day, err = strconv.ParseInt(q, 10, 64)
|
||||
if err != nil || day < 0 {
|
||||
httpError(w, http.StatusBadRequest, "day must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
data, err = s.st.DownlinkAt(r.Context(), p.ID, id, day)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpError(w, http.StatusNotFound, "no downlink for that day")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data, day, err = s.st.Downlink(r.Context(), p.ID, id)
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
|
||||
@@ -118,6 +118,7 @@ func TestEndToEnd(t *testing.T) {
|
||||
if b := do("GET", ship+"/downlink", nil, 200); len(b) != cfg.CommBytes || string(b[:4]) != "PING" {
|
||||
t.Fatalf("downlink = %q", b[:8])
|
||||
}
|
||||
do("GET", ship+"/downlinks", nil, 200)
|
||||
var ships []struct{ Status string }
|
||||
json.Unmarshal(do("GET", "/ships", nil, 200), &ships)
|
||||
if len(ships) != 1 || ships[0].Status != store.StatusActive {
|
||||
@@ -131,6 +132,20 @@ func TestEndToEnd(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both days' downlinks are kept: day 0 carries PING.
|
||||
var hist struct{ Days []int64 }
|
||||
json.Unmarshal(do("GET", ship+"/downlinks", nil, 200), &hist)
|
||||
if len(hist.Days) != 2 || hist.Days[0] != 0 || hist.Days[1] != 1 {
|
||||
t.Fatalf("downlink days = %v", hist.Days)
|
||||
}
|
||||
if b := do("GET", ship+"/downlink?day=0", nil, 200); string(b[:4]) != "PING" {
|
||||
t.Fatalf("day 0 downlink = %q", b[:8])
|
||||
}
|
||||
do("GET", ship+"/downlink?day=1", nil, 200)
|
||||
do("GET", ship+"/downlink?day=7", nil, 404)
|
||||
do("GET", ship+"/downlink?day=x", nil, 400)
|
||||
do("GET", ship+"/downlink?day=-1", nil, 400)
|
||||
|
||||
mem := sim.NewState(cfg)
|
||||
in := sim.DayInput{
|
||||
Launches: []sim.Launch{{ShipID: reg.ShipID, Owner: 1, Program: prog}},
|
||||
@@ -147,6 +162,36 @@ func TestEndToEnd(t *testing.T) {
|
||||
t.Fatal("state after DB round-trip differs from in-memory run")
|
||||
}
|
||||
|
||||
// Scrapping: only a ship in the belt, effective on the next run, which also hands out a new ship.
|
||||
var reg2 struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ShipID int64 `json:"ship_id"`
|
||||
}
|
||||
json.Unmarshal(do("POST", "/register", []byte(`{"name":"bob"}`), 201), ®2)
|
||||
do("POST", "/ships/"+itoa(reg2.ShipID)+"/scrap", nil, 404) // ann cannot scrap bob's ship
|
||||
do("POST", "/ships/9999/scrap", nil, 404)
|
||||
key = reg2.APIKey
|
||||
do("POST", ship+"/scrap", nil, 404) // bob cannot scrap ann's ship
|
||||
do("POST", "/ships/"+itoa(reg2.ShipID)+"/scrap", nil, 409) // docked, not in the belt
|
||||
key = reg.APIKey
|
||||
do("POST", ship+"/scrap", nil, 202)
|
||||
do("POST", ship+"/scrap", nil, 409) // already queued
|
||||
var fleet []struct {
|
||||
ID int64
|
||||
Status string
|
||||
}
|
||||
json.Unmarshal(do("GET", "/ships", nil, 200), &fleet)
|
||||
if len(fleet) != 1 || fleet[0].Status != store.StatusScrapping {
|
||||
t.Fatalf("fleet while scrapping = %+v", fleet)
|
||||
}
|
||||
if _, err := runner.RunNextDay(ctx, st, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.Unmarshal(do("GET", "/ships", nil, 200), &fleet)
|
||||
if len(fleet) != 1 || fleet[0].Status != store.StatusInventory || fleet[0].ID == reg.ShipID {
|
||||
t.Fatalf("fleet after scrapping = %+v, want one new docked ship", fleet)
|
||||
}
|
||||
|
||||
// Changing the config on an existing world is refused.
|
||||
cfg.Seed = 99
|
||||
if _, err := runner.RunNextDay(ctx, st, cfg); err == nil {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"halcyon/store"
|
||||
)
|
||||
|
||||
// clientIP is the address a request really came from. Behind our own reverse
|
||||
// proxy (a loopback or private peer) the proxy's X-Forwarded-For names it;
|
||||
// otherwise the header is untrusted and ignored.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate()) {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
first := strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
if net.ParseIP(first) != nil {
|
||||
return first
|
||||
}
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// audit records something a player did. It is best effort: a failure to write
|
||||
// the log is reported but never fails the player's request.
|
||||
func (s *Server) audit(r *http.Request, player int64, action string, ship int64, slot int, detail map[string]any) {
|
||||
if detail == nil {
|
||||
detail = map[string]any{}
|
||||
}
|
||||
b, _ := json.Marshal(detail)
|
||||
err := s.st.AddAudit(context.WithoutCancel(r.Context()), store.AuditEntry{
|
||||
At: time.Now().Unix(), PlayerID: player, Action: action, Ship: ship, Slot: slot,
|
||||
Detail: string(b), IP: clientIP(r),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("audit: could not record %s for player %d: %v", action, player, err)
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -20,13 +20,13 @@ import (
|
||||
)
|
||||
|
||||
// newSite starts the API with the web front end mounted, as cmd/server does.
|
||||
func newSite(t *testing.T, manualPDF string) (*httptest.Server, config.Config) {
|
||||
srv, cfg, _ := newSiteStore(t, manualPDF)
|
||||
func newSite(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config) {
|
||||
srv, cfg, _ := newSiteStore(t, manualPDF, opts...)
|
||||
return srv, cfg
|
||||
}
|
||||
|
||||
// newSiteStore is newSite, also returning the database so tests can run a simulated day.
|
||||
func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Config, *store.Store) {
|
||||
func newSiteStore(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config, *store.Store) {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
|
||||
@@ -49,7 +49,7 @@ func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Conf
|
||||
if err := st.Migrate(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := api.New(st, cfg)
|
||||
mux := api.New(st, cfg, opts...)
|
||||
mux.Handle("/", web.Handler(manualPDF))
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
@@ -182,3 +182,33 @@ func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) {
|
||||
t.Fatalf("range: %d %q", rr.StatusCode, part)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPageIsServedWithSafeHeaders(t *testing.T) {
|
||||
srv, _ := newSite(t, "")
|
||||
resp, body := get(t, srv.URL+"/admin/")
|
||||
if resp.StatusCode != 200 || !strings.Contains(body, "Halcyon Admin") {
|
||||
t.Fatalf("/admin/: %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
|
||||
t.Error("the admin page needs the site's Content-Security-Policy")
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("X-Robots-Tag"), "noindex") || resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("admin page headers: robots=%q cache=%q", resp.Header.Get("X-Robots-Tag"), resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
for _, path := range []string{"/admin/admin.js", "/admin/admin.css"} {
|
||||
if r, _ := get(t, srv.URL+path); r.StatusCode != 200 {
|
||||
t.Errorf("%s: %d", path, r.StatusCode)
|
||||
}
|
||||
}
|
||||
// /admin (no slash) reaches the same page.
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
r, err := client.Get(srv.URL + "/admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
// The Location is relative ("admin/"), which a browser resolves to /admin/.
|
||||
if loc := r.Header.Get("Location"); r.StatusCode != 301 || (loc != "admin/" && loc != "/admin/") {
|
||||
t.Errorf("/admin: %d -> %q, want a redirect to /admin/", r.StatusCode, loc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ func (s *Server) putSlot(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "code_save", 0, n, map[string]any{"name": req.Name, "bytes": len(req.Source), "sha256": sha256Hex([]byte(req.Source))})
|
||||
writeJSON(w, http.StatusOK, slotView{Slot: n, Name: req.Name})
|
||||
}
|
||||
|
||||
@@ -146,6 +147,7 @@ func (s *Server) deleteSlot(w http.ResponseWriter, r *http.Request, p store.Play
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "code_clear", 0, n, nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -173,5 +175,6 @@ func (s *Server) launchFromSlot(w http.ResponseWriter, r *http.Request, p store.
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "launch", ship, n, map[string]any{"slot_name": sl.Name, "program_bytes": len(prog), "program_sha256": sha256Hex(prog)})
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"slot": n, "program_bytes": len(prog)})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user