This commit is contained in:
root
2026-10-01 22:16:48 +02:00
parent b6d1909d7f
commit 9dfdf79fa7
39 changed files with 4560 additions and 181 deletions
+361
View File
@@ -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))
}
+83
View File
@@ -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")
}
}
+231
View File
@@ -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)
}
+473
View File
@@ -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
View File
@@ -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
View File
@@ -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
+45
View File
@@ -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), &reg2)
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 {
+55
View File
@@ -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
View File
@@ -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)
}
}
+3
View File
@@ -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)})
}