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 }