diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..f6af221 --- /dev/null +++ b/server/main.go @@ -0,0 +1,459 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/websocket" + "github.com/jackc/pgx/v5/pgxpool" +) + +type config struct { + Port string + DatabaseURL string + Issuer string + ClientID string + AllowedOrigins map[string]bool + WorldSeed string +} + +type claims struct { + Subject string `json:"sub"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + AuthorizedParty string `json:"azp"` + Audience interface{} `json:"aud"` +} + +type contextKey string + +const claimsKey contextKey = "claims" + +type ticket struct { + Subject string + Name string + Expires time.Time +} + +type ticketStore struct { + mu sync.Mutex + items map[string]ticket +} + +func newTicketStore() *ticketStore { return &ticketStore{items: map[string]ticket{}} } +func (s *ticketStore) issue(subject, name string) (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + value := base64.RawURLEncoding.EncodeToString(buf) + s.mu.Lock() + s.items[value] = ticket{Subject: subject, Name: name, Expires: time.Now().Add(30 * time.Second)} + s.mu.Unlock() + return value, nil +} +func (s *ticketStore) consume(value string) (ticket, bool) { + s.mu.Lock() + defer s.mu.Unlock() + item, ok := s.items[value] + delete(s.items, value) + if !ok || time.Now().After(item.Expires) { + return ticket{}, false + } + return item, true +} +func (s *ticketStore) prune() { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for key, item := range s.items { + if now.After(item.Expires) { + delete(s.items, key) + } + } +} + +type playerState struct { + ID string `json:"id"` + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + Angle float64 `json:"angle"` + Hull int `json:"hull"` + Online bool `json:"online"` +} + +type clientMessage struct { + Type string `json:"type"` + X float64 `json:"x"` + Y float64 `json:"y"` + Angle float64 `json:"angle"` + Hull int `json:"hull"` +} + +type serverMessage struct { + Type string `json:"type"` + PlayerID string `json:"playerId,omitempty"` + WorldSeed string `json:"worldSeed,omitempty"` + Players []playerState `json:"players,omitempty"` + ServerTime int64 `json:"serverTime,omitempty"` +} + +type client struct { + conn *websocket.Conn + writeMu sync.Mutex + state playerState +} + +func (c *client) writeJSON(v any) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + _ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + return c.conn.WriteJSON(v) +} + +type hub struct { + mu sync.RWMutex + clients map[string]*client + worldSeed string +} + +func newHub(seed string) *hub { return &hub{clients: map[string]*client{}, worldSeed: seed} } +func (h *hub) add(c *client) { + h.mu.Lock() + if previous := h.clients[c.state.ID]; previous != nil && previous != c { + _ = previous.conn.Close() + } + h.clients[c.state.ID] = c + h.mu.Unlock() +} +func (h *hub) remove(id string, c *client) { + h.mu.Lock() + if h.clients[id] == c { + delete(h.clients, id) + } + h.mu.Unlock() +} +func (h *hub) update(id string, msg clientMessage) { + h.mu.Lock() + if c := h.clients[id]; c != nil { + c.state.X = clampFloat(msg.X, 0, 3600) + c.state.Y = clampFloat(msg.Y, 0, 3600) + c.state.Angle = normalizeAngle(msg.Angle) + c.state.Hull = clampInt(msg.Hull, 0, 500) + } + h.mu.Unlock() +} +func (h *hub) snapshot() ([]*client, []playerState) { + h.mu.RLock() + defer h.mu.RUnlock() + clients := make([]*client, 0, len(h.clients)) + players := make([]playerState, 0, len(h.clients)) + for _, c := range h.clients { + clients = append(clients, c) + players = append(players, c.state) + } + return clients, players +} +func (h *hub) run(ctx context.Context) { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + clients, players := h.snapshot() + msg := serverMessage{Type: "snapshot", Players: players, ServerTime: now.UnixMilli()} + for _, c := range clients { + if err := c.writeJSON(msg); err != nil { + _ = c.conn.Close() + } + } + } + } +} + +func clampFloat(v, min, max float64) float64 { + if math.IsNaN(v) || math.IsInf(v, 0) { + return min + } + if v < min { + return min + } + if v > max { + return max + } + return v +} +func clampInt(v, min, max int) int { + if v < min { + return min + } + if v > max { + return max + } + return v +} +func normalizeAngle(v float64) float64 { + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + v = math.Mod(v, 360) + if v < 0 { + v += 360 + } + return v +} + +func loadConfig() (config, error) { + origins := map[string]bool{} + for _, origin := range strings.Split(env("ALLOWED_ORIGINS", "https://airship.arrowstack.dev"), ",") { + origin = strings.TrimSpace(origin) + if origin != "" { + origins[origin] = true + } + } + cfg := config{ + Port: env("PORT", "8080"), DatabaseURL: os.Getenv("DATABASE_URL"), + Issuer: os.Getenv("KEYCLOAK_ISSUER"), ClientID: os.Getenv("KEYCLOAK_CLIENT_ID"), + AllowedOrigins: origins, WorldSeed: env("WORLD_SEED", "SKYWARD-001"), + } + if cfg.DatabaseURL == "" || cfg.Issuer == "" || cfg.ClientID == "" { + return cfg, errors.New("DATABASE_URL, KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID are required") + } + return cfg, nil +} +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func migrate(ctx context.Context, db *pgxpool.Pool) error { + _, err := db.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS player_profiles ( + keycloak_sub TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + credits INTEGER NOT NULL DEFAULT 650, + scrap INTEGER NOT NULL DEFAULT 0, + progression JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `) + return err +} +func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error { + _, err := db.Exec(ctx, ` + INSERT INTO player_profiles (keycloak_sub, display_name) + VALUES ($1, $2) + ON CONFLICT (keycloak_sub) DO UPDATE SET + display_name = EXCLUDED.display_name, + updated_at = now(), + last_seen_at = now() + `, sub, name) + return err +} + +type app struct { + cfg config + db *pgxpool.Pool + verifier *oidc.IDTokenVerifier + tickets *ticketStore + hub *hub + upgrader websocket.Upgrader +} + +func (a *app) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + raw := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) + token, err := a.verifier.Verify(r.Context(), raw) + if err != nil { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + var c claims + if err := token.Claims(&c); err != nil || c.Subject == "" { + http.Error(w, "invalid claims", http.StatusUnauthorized) + return + } + if !audienceMatches(c, a.cfg.ClientID) { + http.Error(w, "wrong client", http.StatusForbidden) + return + } + if c.PreferredUsername == "" { + c.PreferredUsername = c.Name + } + if c.PreferredUsername == "" { + c.PreferredUsername = "Captain" + } + if err := ensurePlayer(r.Context(), a.db, c.Subject, c.PreferredUsername); err != nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, c))) + }) +} +func audienceMatches(c claims, clientID string) bool { + if c.AuthorizedParty == clientID { + return true + } + switch aud := c.Audience.(type) { + case string: + return aud == clientID + case []interface{}: + for _, item := range aud { + if s, ok := item.(string); ok && s == clientID { + return true + } + } + } + return false +} +func (a *app) cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && a.cfg.AllowedOrigins[origin] { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} +func jsonResponse(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} +func (a *app) health(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), time.Second) + defer cancel() + if err := a.db.Ping(ctx); err != nil { + jsonResponse(w, 503, map[string]any{"ok": false, "database": false}) + return + } + jsonResponse(w, 200, map[string]any{"ok": true, "database": true, "players": len(a.hub.clients), "worldSeed": a.cfg.WorldSeed}) +} +func (a *app) me(w http.ResponseWriter, r *http.Request) { + c := r.Context().Value(claimsKey).(claims) + jsonResponse(w, 200, map[string]any{"id": c.Subject, "name": c.PreferredUsername, "worldSeed": a.cfg.WorldSeed}) +} +func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + c := r.Context().Value(claimsKey).(claims) + value, err := a.tickets.issue(c.Subject, c.PreferredUsername) + if err != nil { + http.Error(w, "ticket unavailable", http.StatusInternalServerError) + return + } + jsonResponse(w, 201, map[string]any{"ticket": value, "expiresIn": 30}) +} +func (a *app) ws(w http.ResponseWriter, r *http.Request) { + value := r.URL.Query().Get("ticket") + t, ok := a.tickets.consume(value) + if !ok { + http.Error(w, "invalid websocket ticket", http.StatusUnauthorized) + return + } + conn, err := a.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := &client{conn: conn, state: playerState{ID: t.Subject, Name: t.Name, X: 1200, Y: 1550, Angle: 0, Hull: 100, Online: true}} + a.hub.add(c) + defer func() { + a.hub.remove(t.Subject, c) + _ = conn.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = ensurePlayer(ctx, a.db, t.Subject, t.Name) + }() + _ = c.writeJSON(serverMessage{Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed, ServerTime: time.Now().UnixMilli()}) + conn.SetReadLimit(4096) + for { + _ = conn.SetReadDeadline(time.Now().Add(45 * time.Second)) + var msg clientMessage + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.Type == "state" { + a.hub.update(t.Subject, msg) + } + } +} + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatal(err) + } + ctx := context.Background() + db, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatal(err) + } + defer db.Close() + if err := db.Ping(ctx); err != nil { + log.Fatalf("database ping: %v", err) + } + if err := migrate(ctx, db); err != nil { + log.Fatalf("migration: %v", err) + } + provider, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + log.Fatalf("oidc provider: %v", err) + } + verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) + application := &app{cfg: cfg, db: db, verifier: verifier, tickets: newTicketStore(), hub: newHub(cfg.WorldSeed)} + application.upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + return origin == "" || cfg.AllowedOrigins[origin] + }} + go application.hub.run(ctx) + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for range ticker.C { + application.tickets.prune() + } + }() + mux := http.NewServeMux() + mux.HandleFunc("/health", application.health) + mux.Handle("/api/me", application.cors(application.authenticate(http.HandlerFunc(application.me)))) + mux.Handle("/api/ws-ticket", application.cors(application.authenticate(http.HandlerFunc(application.wsTicket)))) + mux.HandleFunc("/ws", application.ws) + server := &http.Server{Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second} + log.Printf("airship game server listening on :%s world=%s", cfg.Port, cfg.WorldSeed) + log.Fatal(server.ListenAndServe()) +} + +func init() { _ = fmt.Sprintf }