mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
b8bcc1f3c1
Prerequisite for wiring the allocator to publish rosters. The signing key is a shared HMAC secret mounted into both the allocator and the allocated game server; without a key ID, rotating it would invalidate every authorisation already issued for an in-flight match, because a server holding only the new key cannot verify a token signed with the old one. Add KeyID to JoinAuthorisation and append it to the canonical claim bytes, so it is covered by the signature and cannot be repointed at a different key than the one that actually signed. Allocated servers now hold a set of currently-valid keys and select by ID: a rotation publishes the new key alongside the old, and the old is dropped once no live match can still reference it. The key file becomes a JSON map of key ID to base64 key. A file of raw key bytes is still accepted as a single key under the empty ID, which is what an unrotated deployment and the kind fixture use. Game/scripts/match_net.gd builds the canonical bytes independently, so it changes in lockstep; the cross-language golden token in test_match_net.gd is regenerated from the Go implementation and now carries a key ID. Added tests cover accepting either key mid-rotation, rejecting a retired key ID, and rejecting a token whose key ID was swapped to name a key the server does hold. Go suite and 223 Godot tests pass.
227 lines
7.6 KiB
Go
227 lines
7.6 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
const RankedReconnectGrace = 60 * time.Second
|
|
|
|
var rankedAbandonCooldowns = [...]time.Duration{
|
|
5 * time.Minute,
|
|
15 * time.Minute,
|
|
time.Hour,
|
|
24 * time.Hour,
|
|
}
|
|
|
|
var (
|
|
ErrJoinAuthorisation = fmt.Errorf("invalid join authorisation")
|
|
ErrConnectionFenced = fmt.Errorf("connection generation is fenced")
|
|
ErrReconnectExpired = fmt.Errorf("reconnect grace expired")
|
|
)
|
|
|
|
// JoinAuthorisation is the signed payload an adapter obtains from the secure
|
|
// backend. Signature verification is deliberately outside this pure policy
|
|
// package; every identity, match, slot, server and protocol field is still
|
|
// checked here before a lease can be admitted.
|
|
type JoinAuthorisation struct {
|
|
MatchID string
|
|
ServerID string
|
|
PlayerID string
|
|
SteamID string
|
|
Slot int
|
|
Team int
|
|
Protocol string
|
|
Generation uint64
|
|
ExpiresAt time.Time
|
|
// KeyID names the signing key so the allocator can rotate without
|
|
// invalidating authorisations already issued for in-flight matches: the
|
|
// game server holds a set of currently-valid keys and selects by this ID.
|
|
// It is part of the signed bytes, so it cannot be swapped to point at a
|
|
// different key than the one that actually signed.
|
|
KeyID string
|
|
}
|
|
|
|
type rankedConnection struct {
|
|
PlayerID string
|
|
Slot int
|
|
Team int
|
|
SteamID string
|
|
Generation uint64
|
|
ConnectedAt time.Time
|
|
LostAt time.Time
|
|
Abandoned bool
|
|
}
|
|
|
|
type RankedConnections struct {
|
|
MatchID string
|
|
ServerID string
|
|
Protocol string
|
|
players map[string]rankedConnection
|
|
}
|
|
|
|
func NewRankedConnections(matchID, serverID, protocol string, players []JoinAuthorisation) (*RankedConnections, error) {
|
|
if matchID == "" || serverID == "" || protocol == "" || len(players) != 6 {
|
|
return nil, fmt.Errorf("%w: invalid ranked match", ErrJoinAuthorisation)
|
|
}
|
|
r := &RankedConnections{MatchID: matchID, ServerID: serverID, Protocol: protocol, players: make(map[string]rankedConnection, len(players))}
|
|
for _, auth := range players {
|
|
if err := r.validate(auth, time.Time{}); err != nil || auth.Generation != 1 || auth.ExpiresAt.IsZero() {
|
|
return nil, fmt.Errorf("%w: invalid initial roster", ErrJoinAuthorisation)
|
|
}
|
|
if _, exists := r.players[auth.PlayerID]; exists {
|
|
return nil, fmt.Errorf("%w: duplicate player", ErrJoinAuthorisation)
|
|
}
|
|
for _, existing := range r.players {
|
|
if existing.Slot == auth.Slot {
|
|
return nil, fmt.Errorf("%w: duplicate slot", ErrJoinAuthorisation)
|
|
}
|
|
}
|
|
r.players[auth.PlayerID] = rankedConnection{PlayerID: auth.PlayerID, SteamID: auth.SteamID, Slot: auth.Slot, Team: auth.Team, Generation: 1}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (r *RankedConnections) validate(auth JoinAuthorisation, now time.Time) error {
|
|
if auth.MatchID != r.MatchID || auth.ServerID != r.ServerID || auth.Protocol != r.Protocol || auth.PlayerID == "" || auth.SteamID == "" || auth.Slot < 0 || auth.Slot > 5 || auth.Team < 0 || auth.Team > 1 || auth.Slot/3 != auth.Team || auth.ExpiresAt.IsZero() {
|
|
return ErrJoinAuthorisation
|
|
}
|
|
if !now.IsZero() && !now.Before(auth.ExpiresAt) {
|
|
return ErrJoinAuthorisation
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Admit accepts the current generation or atomically reclaims a disconnected
|
|
// slot with the next server-owned generation. A newer generation fences every
|
|
// older connection, even if the backend is temporarily unavailable.
|
|
func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64, error) {
|
|
if now.IsZero() {
|
|
return 0, ErrJoinAuthorisation
|
|
}
|
|
if err := r.validate(auth, now); err != nil {
|
|
return 0, err
|
|
}
|
|
player, ok := r.players[auth.PlayerID]
|
|
if !ok || player.SteamID != auth.SteamID || player.Slot != auth.Slot || player.Team != auth.Team {
|
|
return 0, ErrJoinAuthorisation
|
|
}
|
|
// Generation in the authorisation identifies the backend-issued assignment
|
|
// (currently 1); player.Generation is the server-owned live connection
|
|
// generation and changes on every reclaim.
|
|
if auth.Generation != 1 {
|
|
return 0, ErrConnectionFenced
|
|
}
|
|
if player.Abandoned {
|
|
return 0, ErrReconnectExpired
|
|
}
|
|
if !player.ConnectedAt.IsZero() && player.LostAt.IsZero() {
|
|
return 0, ErrConnectionFenced
|
|
}
|
|
if !player.LostAt.IsZero() {
|
|
if now.Before(player.LostAt) {
|
|
return 0, ErrJoinAuthorisation
|
|
}
|
|
if now.Sub(player.LostAt) > RankedReconnectGrace {
|
|
return 0, ErrReconnectExpired
|
|
}
|
|
player.Generation++
|
|
}
|
|
player.ConnectedAt = now
|
|
player.LostAt = time.Time{}
|
|
r.players[auth.PlayerID] = player
|
|
return player.Generation, nil
|
|
}
|
|
|
|
func (r *RankedConnections) Disconnect(playerID string, generation uint64, now time.Time) error {
|
|
if now.IsZero() {
|
|
return ErrJoinAuthorisation
|
|
}
|
|
player, ok := r.players[playerID]
|
|
if !ok {
|
|
return ErrJoinAuthorisation
|
|
}
|
|
if generation != player.Generation {
|
|
return ErrConnectionFenced
|
|
}
|
|
if player.Abandoned {
|
|
return ErrReconnectExpired
|
|
}
|
|
if player.ConnectedAt.IsZero() || !player.LostAt.IsZero() || now.Before(player.ConnectedAt) {
|
|
return ErrConnectionFenced
|
|
}
|
|
player.LostAt = now
|
|
r.players[playerID] = player
|
|
return nil
|
|
}
|
|
|
|
type Abandonment struct {
|
|
PlayerID string
|
|
Cooldown time.Duration
|
|
AbandonedAt time.Time
|
|
}
|
|
|
|
// ReconnectParticipant is the durable subset needed to evaluate an expired
|
|
// live reconnect lease. Connected players are deliberately absent: only a
|
|
// persisted disconnect can start a player-caused abandon clock.
|
|
type ReconnectParticipant struct {
|
|
PlayerID string
|
|
DisconnectedAt time.Time
|
|
}
|
|
|
|
// PlanRankedAbandonments turns expired durable reconnect leases into the
|
|
// same rolling cooldown ladder used by pre-live ranked no-shows. Future
|
|
// disconnect timestamps are ignored rather than penalised: they can only be
|
|
// an infrastructure clock anomaly, not a player abandonment.
|
|
func PlanRankedAbandonments(now time.Time, participants []ReconnectParticipant, priorAbandons map[string][]time.Time) ([]Abandonment, error) {
|
|
if now.IsZero() {
|
|
return nil, fmt.Errorf("invalid reconnect-abandonment time")
|
|
}
|
|
seen := make(map[string]bool, len(participants))
|
|
result := make([]Abandonment, 0, len(participants))
|
|
for _, participant := range participants {
|
|
if participant.PlayerID == "" || participant.DisconnectedAt.IsZero() || seen[participant.PlayerID] {
|
|
return nil, fmt.Errorf("invalid reconnect participant")
|
|
}
|
|
seen[participant.PlayerID] = true
|
|
if now.Before(participant.DisconnectedAt) || now.Sub(participant.DisconnectedAt) <= RankedReconnectGrace {
|
|
continue
|
|
}
|
|
result = append(result, Abandonment{PlayerID: participant.PlayerID, Cooldown: abandonCooldown(priorAbandons[participant.PlayerID], now), AbandonedAt: now})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].PlayerID < result[j].PlayerID })
|
|
return result, nil
|
|
}
|
|
|
|
// ExpireGrace marks every disconnected player whose 60-second reclaim window
|
|
// has elapsed. The returned list is lexical for stable audit/event ordering.
|
|
func (r *RankedConnections) ExpireGrace(now time.Time, priorAbandons map[string][]time.Time) []Abandonment {
|
|
result := make([]Abandonment, 0)
|
|
for id, player := range r.players {
|
|
if player.Abandoned || player.LostAt.IsZero() || now.Sub(player.LostAt) <= RankedReconnectGrace {
|
|
continue
|
|
}
|
|
player.Abandoned = true
|
|
r.players[id] = player
|
|
result = append(result, Abandonment{PlayerID: id, Cooldown: abandonCooldown(priorAbandons[id], now), AbandonedAt: now})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].PlayerID < result[j].PlayerID })
|
|
return result
|
|
}
|
|
|
|
func abandonCooldown(history []time.Time, now time.Time) time.Duration {
|
|
cutoff := now.Add(-7 * 24 * time.Hour)
|
|
count := 0
|
|
for _, at := range history {
|
|
if !at.Before(cutoff) && !at.After(now) {
|
|
count++
|
|
}
|
|
}
|
|
index := count
|
|
if index >= len(rankedAbandonCooldowns) {
|
|
index = len(rankedAbandonCooldowns) - 1
|
|
}
|
|
return rankedAbandonCooldowns[index]
|
|
}
|