mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
219 lines
7.0 KiB
Go
219 lines
7.0 KiB
Go
package domain
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
ResultDeliveryAlertAfter = 5 * time.Minute
|
|
ResultDeliveryReviewAfter = 30 * time.Minute
|
|
)
|
|
|
|
type IntegrityState string
|
|
|
|
const (
|
|
IntegrityCertified IntegrityState = "CERTIFIED"
|
|
IntegritySuppressed IntegrityState = "SUPPRESSED"
|
|
IntegrityReview IntegrityState = "REVIEW"
|
|
)
|
|
|
|
type IntegrityEvidence struct {
|
|
RosterAuthoritative bool
|
|
SimulationAuthoritative bool
|
|
ResultAuthoritative bool
|
|
RegionalPlayFair bool
|
|
DeliveryAvailable bool
|
|
}
|
|
|
|
// ClassifyIntegrity deliberately ignores DeliveryAvailable when deciding
|
|
// rating eligibility: a healthy match remains rated while the control plane
|
|
// is temporarily unable to acknowledge its result.
|
|
func ClassifyIntegrity(evidence IntegrityEvidence) IntegrityState {
|
|
if !evidence.RosterAuthoritative || !evidence.SimulationAuthoritative || !evidence.ResultAuthoritative || !evidence.RegionalPlayFair {
|
|
return IntegritySuppressed
|
|
}
|
|
return IntegrityCertified
|
|
}
|
|
|
|
var (
|
|
ErrResultBinding = fmt.Errorf("result workload binding rejected")
|
|
ErrResultConflict = fmt.Errorf("conflicting result")
|
|
ErrResultInvalid = fmt.Errorf("invalid match result")
|
|
ErrReceiptMissing = fmt.Errorf("result receipt not found")
|
|
)
|
|
|
|
// WorkloadBinding is the identity extracted and validated by the secure
|
|
// credential adapter. The domain compares every binding dimension recorded by
|
|
// allocation; a shared service-account class is not sufficient on its own.
|
|
type WorkloadBinding struct {
|
|
Issuer string
|
|
Audience string
|
|
Namespace string
|
|
ServiceAcct string
|
|
PodUID string
|
|
GameServerUID string
|
|
AllocationID string
|
|
MatchID string
|
|
ServerID string
|
|
}
|
|
|
|
type MatchResult struct {
|
|
MatchID string
|
|
ServerID string
|
|
ResultNonce string
|
|
Team0Score int
|
|
Team1Score int
|
|
IntegrityState IntegrityState
|
|
}
|
|
|
|
type ResultReceipt struct {
|
|
ResultID string
|
|
MatchID string
|
|
ResultNonce string
|
|
PayloadDigest [32]byte
|
|
IntegrityState IntegrityState
|
|
ReceivedAt time.Time
|
|
CommittedAt time.Time
|
|
}
|
|
|
|
// ResultDigest exposes the canonical payload digest to transport adapters;
|
|
// callers still need the domain validation and workload binding policy.
|
|
func ResultDigest(result MatchResult) [32]byte { return resultDigest(result) }
|
|
|
|
type ResultStore struct {
|
|
expected WorkloadBinding
|
|
receipts map[string]ResultReceipt
|
|
}
|
|
|
|
func NewResultStore(expected WorkloadBinding) (*ResultStore, error) {
|
|
if err := validateBinding(expected); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ResultStore{expected: expected, receipts: make(map[string]ResultReceipt)}, nil
|
|
}
|
|
|
|
// Submit is the durable-transaction boundary in miniature. Production code
|
|
// must persist the receipt, match transition, participant penalties/ratings,
|
|
// and outbox event atomically around this same decision.
|
|
func (s *ResultStore) Submit(resultID string, result MatchResult, binding WorkloadBinding, now time.Time) (ResultReceipt, bool, error) {
|
|
if resultID == "" || !sameBinding(s.expected, binding) {
|
|
return ResultReceipt{}, false, ErrResultBinding
|
|
}
|
|
if now.IsZero() {
|
|
return ResultReceipt{}, false, ErrResultInvalid
|
|
}
|
|
if err := validateResult(s.expected, result); err != nil {
|
|
return ResultReceipt{}, false, err
|
|
}
|
|
digest := resultDigest(result)
|
|
if prior, ok := s.receipts[result.MatchID]; ok {
|
|
if prior.ResultID == resultID && prior.PayloadDigest == digest {
|
|
return prior, false, nil
|
|
}
|
|
return prior, false, ErrResultConflict
|
|
}
|
|
receipt := ResultReceipt{ResultID: resultID, MatchID: result.MatchID, ResultNonce: result.ResultNonce, PayloadDigest: digest, IntegrityState: result.IntegrityState, ReceivedAt: now}
|
|
s.receipts[result.MatchID] = receipt
|
|
return receipt, true, nil
|
|
}
|
|
|
|
// ResultAnnotation is the non-secret Agones spool representation. Its
|
|
// signature is checked by the workload-credential adapter before Reconcile;
|
|
// the digest check here prevents annotation/payload drift even after trust
|
|
// has been established.
|
|
type ResultAnnotation struct {
|
|
ResultID string
|
|
Result MatchResult
|
|
PayloadDigest [32]byte
|
|
Signature []byte
|
|
}
|
|
|
|
func (s *ResultStore) Reconcile(annotation ResultAnnotation, verify func(ResultAnnotation) bool, binding WorkloadBinding, now time.Time) (ResultReceipt, bool, error) {
|
|
if len(annotation.Signature) == 0 || verify == nil || !verify(annotation) || annotation.PayloadDigest != resultDigest(annotation.Result) {
|
|
return ResultReceipt{}, false, ErrResultBinding
|
|
}
|
|
return s.Submit(annotation.ResultID, annotation.Result, binding, now)
|
|
}
|
|
|
|
func (s *ResultStore) Commit(resultID, matchID string, now time.Time) (ResultReceipt, error) {
|
|
receipt, ok := s.receipts[matchID]
|
|
if !ok || receipt.ResultID != resultID {
|
|
return ResultReceipt{}, ErrReceiptMissing
|
|
}
|
|
if receipt.CommittedAt.IsZero() {
|
|
receipt.CommittedAt = now
|
|
s.receipts[matchID] = receipt
|
|
}
|
|
return receipt, nil
|
|
}
|
|
|
|
type DeliveryHealth string
|
|
|
|
const (
|
|
DeliveryHealthy DeliveryHealth = "HEALTHY"
|
|
DeliveryAlert DeliveryHealth = "ALERT"
|
|
DeliveryReview DeliveryHealth = "REVIEW"
|
|
)
|
|
|
|
func DeliveryStatus(receipt ResultReceipt, now time.Time) DeliveryHealth {
|
|
if !receipt.CommittedAt.IsZero() {
|
|
return DeliveryHealthy
|
|
}
|
|
age := now.Sub(receipt.ReceivedAt)
|
|
if age >= ResultDeliveryReviewAfter {
|
|
return DeliveryReview
|
|
}
|
|
if age >= ResultDeliveryAlertAfter {
|
|
return DeliveryAlert
|
|
}
|
|
return DeliveryHealthy
|
|
}
|
|
|
|
func RatingEligible(receipt ResultReceipt) bool {
|
|
return receipt.IntegrityState == IntegrityCertified
|
|
}
|
|
|
|
func validateBinding(binding WorkloadBinding) error {
|
|
// A Kubernetes JWT supplies the six workload-identity fields below, while
|
|
// the signed workload credential is deliberately bound through the durable
|
|
// allocation record and therefore supplies only allocation/match/server.
|
|
// Accept either complete authority model, but never a partial Kubernetes
|
|
// identity that could accidentally look authenticated.
|
|
if binding.AllocationID == "" || binding.MatchID == "" || binding.ServerID == "" {
|
|
return ErrResultBinding
|
|
}
|
|
kubernetesIdentity := []string{binding.Issuer, binding.Audience, binding.Namespace, binding.ServiceAcct, binding.PodUID, binding.GameServerUID}
|
|
present := 0
|
|
for _, value := range kubernetesIdentity {
|
|
if value != "" {
|
|
present++
|
|
}
|
|
}
|
|
if present != 0 && present != len(kubernetesIdentity) {
|
|
return ErrResultBinding
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sameBinding(a, b WorkloadBinding) bool { return a == b }
|
|
|
|
func validateResult(expected WorkloadBinding, result MatchResult) error {
|
|
if result.MatchID != expected.MatchID || result.ServerID != expected.ServerID || len(result.ResultNonce) < 16 || len(result.ResultNonce) > 128 || result.Team0Score < 0 || result.Team1Score < 0 {
|
|
return ErrResultInvalid
|
|
}
|
|
switch result.IntegrityState {
|
|
case IntegrityCertified, IntegritySuppressed, IntegrityReview:
|
|
return nil
|
|
default:
|
|
return ErrResultInvalid
|
|
}
|
|
}
|
|
|
|
func resultDigest(result MatchResult) [32]byte {
|
|
canonical := result.MatchID + "\x00" + result.ServerID + "\x00" + result.ResultNonce + "\x00" + strconv.Itoa(result.Team0Score) + "\x00" + strconv.Itoa(result.Team1Score) + "\x00" + string(result.IntegrityState)
|
|
return sha256.Sum256([]byte(canonical))
|
|
}
|