mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-12 02:43:43 +00:00
feat(multiplayer): wire structured event logging into server routes
server/observability existed fully unit-tested but was imported by nothing outside its own package -- no HTTP handler ever called it, so its credential redaction protected zero real log output. Wire it into Service via an optional Log field (nil-safe, so every existing Service literal keeps compiling unchanged) and call it from the two workload-authenticated server routes -- register and result -- at every outcome: unauthorized, rejected, conflict and success. Wire cmd/control-plane to actually emit those events as JSON lines on stderr. Add a secret canary test that drives both routes end to end with realistic bearer-token and result-nonce values and asserts neither literal secret appears anywhere in what Service.Log actually received -- a stronger claim than the existing observability unit test, which only proves redact() strips a synthetic value under a denylisted key name. redact() is still key-name-based, not content-based: a future call site that logs a secret under an unlisted key name would not be caught by this test or by redact() itself, only by the same discipline applied here of never putting raw request/token bytes into Fields. Queue, proposal and assignment mutation routes are not wired yet.
This commit is contained in:
+31
-3
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/observability"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 8 << 10
|
||||
@@ -116,9 +117,21 @@ type Service struct {
|
||||
RankedProfiles map[string]domain.RankedProfile
|
||||
TierPolicy domain.TierPolicy
|
||||
RateLimiter *RateLimiter
|
||||
proposalMu sync.Mutex
|
||||
eventsMu sync.Mutex
|
||||
events *eventHub
|
||||
// Log receives a credential-safe structured event for lifecycle-relevant
|
||||
// mutations (currently: server registration and result submission). Nil
|
||||
// is a valid, silent no-op -- every call site must stay optional so
|
||||
// existing Service literals that don't set it keep working unchanged.
|
||||
Log func(observability.Event)
|
||||
proposalMu sync.Mutex
|
||||
eventsMu sync.Mutex
|
||||
events *eventHub
|
||||
}
|
||||
|
||||
// logEvent is a nil-safe wrapper so call sites never need their own guard.
|
||||
func (s *Service) logEvent(event observability.Event) {
|
||||
if s.Log != nil {
|
||||
s.Log(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Handler() http.Handler {
|
||||
@@ -431,6 +444,7 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
now := s.now()
|
||||
binding, err := s.WorkloadVerify(partsAuth[1], now)
|
||||
if err != nil || binding.ServerID != parts[0] {
|
||||
s.logEvent(observability.Event{Event: "server_" + parts[1], ServerID: parts[0], Stage: "unauthorized", OccurredAt: now})
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
@@ -440,17 +454,26 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if input.MatchID == "" || input.MatchID != binding.MatchID || input.ProtocolVersion < 1 || !validImageDigest(input.ImageDigest) {
|
||||
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
return
|
||||
}
|
||||
if err := s.ServerRegistrar.RegisterServer(r.Context(), binding, input.ProtocolVersion, input.AssignmentReady, key, now); err != nil {
|
||||
stage := "invalid"
|
||||
if errors.Is(err, domain.ErrConflict) {
|
||||
stage = "conflict"
|
||||
writeError(w, http.StatusConflict, "conflict")
|
||||
} else {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
}
|
||||
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: stage, OccurredAt: now})
|
||||
return
|
||||
}
|
||||
readyStage := "process_ready"
|
||||
if input.AssignmentReady {
|
||||
readyStage = "assignment_ready"
|
||||
}
|
||||
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: readyStage, OccurredAt: now, Fields: map[string]any{"protocol_version": input.ProtocolVersion}})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
@@ -459,6 +482,7 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if input.MatchID == "" || binding.MatchID != input.MatchID || len(input.ResultNonce) < 16 || len(input.ResultNonce) > 128 || input.Score.Team0 < 0 || input.Score.Team1 < 0 || (input.IntegrityState != domain.IntegrityCertified && input.IntegrityState != domain.IntegritySuppressed && input.IntegrityState != domain.IntegrityReview) {
|
||||
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
return
|
||||
}
|
||||
@@ -469,13 +493,17 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.ResultSubmitter.SubmitResult(r.Context(), key, result, binding, payload, now); err != nil {
|
||||
stage := "invalid"
|
||||
if errors.Is(err, domain.ErrResultConflict) || strings.Contains(err.Error(), "conflict") {
|
||||
stage = "conflict"
|
||||
writeError(w, http.StatusConflict, "conflict")
|
||||
} else {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
}
|
||||
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: stage, OccurredAt: now})
|
||||
return
|
||||
}
|
||||
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: "accepted", OccurredAt: now, Fields: map[string]any{"integrity_state": string(input.IntegrityState), "team_0": input.Score.Team0, "team_1": input.Score.Team1}})
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user