diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index c9a71e25..0f54df9b 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -502,6 +502,13 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head state.expire("Queue ticket expired") elif response_code == HTTPClient.RESPONSE_SERVICE_UNAVAILABLE: state.set_notice("Matchmaking is temporarily unavailable; retrying is safe") + elif response_code == HTTPClient.RESPONSE_UPGRADE_REQUIRED and operation == "queue_create": + # Distinct from the generic queue_create failure below: retrying + # with the same client build can never succeed, so the retry + # offer must not be shown (can_retry_queue_create() checks + # _last_queue_create; clearing it here suppresses "Retry Search"). + _last_queue_create = {} + state.fail("Your client is out of date -- please update to continue searching") elif operation == "ranked_profile": ranked_profile.set_error(detail) elif response_code == HTTPClient.RESPONSE_NOT_FOUND and (operation == "queue_recover" or operation == "proposal_recover"): diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 25236223..1eec9320 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -338,6 +338,24 @@ func test_queue_conflict_response_handler_defers_ticket_recovery() -> void: client.free() +# Covers §8.43's "version-mismatch-specific client messaging": a 426 Upgrade +# Required on queue_create (the server-side floor added alongside this test) +# must surface a distinct, actionable message rather than the server's raw +# generic error string, and must not offer a futile "Retry Search" -- the +# same client build will fail again identically every time. +func test_outdated_client_receives_a_distinct_message_and_no_retry_offer() -> void: + var client := ControlPlaneClient.new() + client._ready() + assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures") + client._operation = "queue_create" + client._last_queue_create = {"ticket_id": "ticket-outdated", "playlist": "casual", "client_build": "build-1", "protocol_version": 4, "key": "outdated-key-123456"} + client._on_request_completed(HTTPRequest.RESULT_SUCCESS, HTTPClient.RESPONSE_UPGRADE_REQUIRED, PackedStringArray(), JSON.stringify({"error": "client_outdated"}).to_utf8_buffer()) + assert_eq(client.state.phase, MatchmakingState.FAILED, "outdated client fails the search") + assert_true(client.state.message.to_lower().contains("update"), "message tells the player to update rather than repeating the raw server error: %s" % client.state.message) + assert_true(not client.can_retry_queue_create(), "retrying with the same outdated client build is never offered") + client.free() + + func test_rest_responses_reject_malformed_resource_identifiers() -> void: var client := ControlPlaneClient.new() client._ready() diff --git a/multiplayer-next.md b/multiplayer-next.md index 37d25f0d..aa469f81 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1243,7 +1243,7 @@ The allocated-runtime result reporter now keeps a completed match in `RESULTS` u | 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read; Godot client projection persists non-secret ticket/proposal state, forces authoritative recovery after restart, and can replay a lost queue-create response with the original ticket/idempotency key; the Go API now exposes an authenticated `/v1/events` WebSocket with bounded per-player queues, strict upgrade/vocabulary validation and REST-resync-safe slow-client failure; Godot `ControlPlaneClient` can connect to the stream using Godot 4.7's handshake-header API, consume validated events, automatically reconnect with bounded backoff, and trigger REST recovery on projection gaps and stream return, including a deferred recovery when an event arrives during an in-flight HTTP mutation; `OutboxDispatcher` now provides ordered at-least-once delivery after durable commit; assignment recovery events carry the durable assignment revision without leaking it into REST JSON; prediction startup now distinguishes the sequence-0 warm-up acknowledgement from genuine missing/evicted history, preventing a false hard-snap during a live match; proposal creation writes one durable targeted `proposal_changed` outbox event per proposal aggregate revision, while result completion writes `match_completed` and production `cmd/control-plane` plus the test-only API harness dispatch both event types through separate filtered consumers | `server/domain/sync.go`, `server/api/events.go`, `server/api/outbox.go`, `server/api/service.go`, `server/store/outbox.go`, `server/store/proposal_sql.go`, `server/cmd/control-plane`, `server/cmd/testkit-api`, `service_test.go`, `outbox_test.go`, `matchmaking_state.gd`, `control_plane_client.gd`, `local_prediction_history.gd`, `net_ship_predictor.gd` and tests cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery, expired-ticket terminal handling, malformed restart snapshots, API-level duplicate-create replay/conflict, authenticated handshake/key rejection, targeted event delivery, exactly-once slow-subscriber closure, invalid-event rejection, delivery-before-ack failure ordering, assignment event/response revision separation, prediction warm-up/hard-resync separation, deferred proposal recovery and proposal/result outbox filtering and delivery failures; Godot 4.7.1 headless project parse and 150-test unit harness pass with compatibility rendering, and a two-process ENet authoritative match smoke passes spawn, movement, prediction, 0% snapshot loss and 0 hard snaps; `cmd/control-plane` provides a signal-bound API role, `cmd/matcher` now supports casual and explicitly enabled ranked roles with durable identity lookup, and `cmd/maintenance` provides bounded season maintenance. **Live multi-process control-plane/game verification now exists**: `scripts/verify_control_plane_integration.sh` runs a real `postgres:17-alpine`, the real `api.Service` (via the new test-only `server/cmd/testkit-api`, wired identically to `cmd/control-plane` except for a fake Steam login — see §8.7), and a real headless Godot client (`control_plane_smoke.gd`) round-tripping login → `fetch_ranked_profile` (expect 404, §8.22) → `queue_create` → heartbeat → `cancel_queue` over an actual network connection — the first time this boundary was exercised end to end rather than against a mock on either side. **The two-player proposal extension now also passes**: `scripts/verify_control_plane_proposal_integration.sh` starts isolated PostgreSQL, the real matcher and testkit API, launches two headless Godot clients, seeds deterministic RTT projections, observes the real WebSocket `OPEN` proposal, accepts from both clients, and verifies both durable tickets plus the proposal reach `ACCEPTED` (including authoritative recovery when the concurrent revision advances). `scripts/run_result_fanout_integration.sh` additionally verifies a real PostgreSQL-backed authenticated WebSocket receives a completed-match event; allocator and Redis fan-out live verification remain | | 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view including the hosted endpoint; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, validate and retain the endpoint, and `connect_to_assignment()` starts only the validated ENet/Steam transport after assignment readiness. **Found and fixed a severe gap this row had previously described as already closed**: `connect_to_assignment()` existed, fully validated, with its own `assignment_connection_started`/`assignment_connection_failed` signals -- but nothing anywhere in the client ever called it. A player who completed the entire queue -> proposal -> allocate -> assign pipeline would reach `ASSIGNED` and see "Your match server is ready" and then simply sit there forever; the transport was never actually started. `ControlPlaneClient._connect_when_assigned()` now calls it automatically the moment `state.phase` reaches `ASSIGNED` (wired into the one call site every queue-shaped HTTP response -- heartbeat, recover, and resync-triggered recover -- already shares, so both the REST poll and the WebSocket-triggered-resync path are covered without a second call site), deferring via `_pending_connect_match_id` if the assignment fetch triggered earlier by `ASSIGNMENT_READY` hasn't completed yet, and guarding against a duplicate/replayed `ASSIGNED` event reattempting the connection. The opaque join authorisation is carried in the MatchNet hello payload rather than the endpoint URL; allocated Godot servers now fail closed unless an operator-mounted JSON roster of control-plane signed envelopes and an HMAC-SHA256 key are present, and MatchNet verifies the canonical Go claim bytes, checks exact token membership plus match/server/protocol/slot/expiry claims before admitting a peer, rejects concurrent reuse of an active token, tracks server-owned reconnect generations across the 60-second reclaim window, fences expired reclaims and invalidates old peers; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows; a workload-authenticated `GET /v1/servers/{serverId}/roster` now returns the complete signed envelope set only for the bound allocation, and the allocated supervisor atomically materializes it before launching Godot; live authenticated Godot/PostgreSQL assignment verification now exists via `scripts/verify_assignment_integration.sh`, which seeds a durable player-scoped `ASSIGNMENT_READY` row behind the real testkit API and verifies the returned endpoint/join authorisation over HTTP/JSON (including the live numeric-decoding boundary); `scripts/run_supervisor_integration.sh` additionally verifies the allocated-server process-ready → roster fetch/materialization → assignment-ready registration path against real PostgreSQL and the real API, with the workload token recovered from an Agones-shaped GameServer annotation | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `control_plane_client.gd`, `match_net.gd`, `server_boot.gd`, `server_config.gd`, `test_assignment_state.gd`, `test_control_plane_client.gd`, `test_match_net.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql`, `server/allocator/allocator_integration_test.go`, `server/supervisor/supervisor_integration_test.go`, `server/supervisor/supervisor.go` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport/endpoint boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation, signed-claim binding, strict endpoint splitting, allowlisted claim rejection, canonical HMAC interoperability, forged-signature rejection, duplicate active-token rejection, reclaim generation/expiry behavior, workload-authenticated roster delivery, atomic file installation, real allocator reconciliation, real supervisor registration and 150-test Godot compatibility coverage; the three-process disconnect/reclaim smoke passes ship retention, replacement drive and old-peer invalidation; two new `test_control_plane_client.gd` tests cover the connect-wiring fix directly: `test_client_starts_the_transport_once_the_ticket_reaches_assigned` proves a ready, fresh assignment plus an `ASSIGNED` ticket update actually starts the transport (`state.phase` advances to `CONNECTING`, `connect_to_assignment`'s own signal fires) and that a duplicate attempt is refused, `test_client_defers_the_connect_until_the_assignment_fetch_completes` proves the opposite ordering (an `ASSIGNED` update before the assignment fetch completes) defers rather than either connecting with stale data or erroring; verified against the real Godot 4.7.1 binary (216/216, no crash), the full local gate and the ENet integration suite. **"Dynamic per-match launch flags" was stale, corrected in §8.16**: `agones.Client.Allocate` already requests arena-path/playlist/region/build/protocol/transport as Agones annotations and `supervisor.withAllocatedCompatibility` already overlays them onto the launch command, fully tested and wired into `Supervisor.Start()`. SDR relay-ticket installation and live Agones cluster integration remain | | 8.42 `[D:8.22,8.23,8.24,8.40]` | **IN PROGRESS.** `RankedProfileState` and `ControlPlaneClient.fetch_ranked_profile()` expose the backend-authoritative rating/RD/volatility/games/tier/provisional/season view; matchmaking UI displays provisional/tier status without client-side rating math; runnable API binaries now wire the backend-owned default tier policy; live authenticated profile verification now exists via `scripts/verify_ranked_profile_integration.sh`, which seeds a durable rating and verifies the populated profile over real PostgreSQL, Go HTTP, and Godot JSON | `test_control_plane_client.gd` validates profile shape, numeric safety and provisional display; the real populated profile path now covers durable rating/games/tier/provisional decoding and caught the missing runtime tier-policy wiring; committed revision after reconnect, abandon status and season countdown remain dependent on live auth/backend events and Godot runtime verification | -| 8.43 `[D:8.39,8.40,8.41]` | **IN PROGRESS.** Matchmaking client now distinguishes expired queue recovery, session expiry, missing records and retryable control-plane outages; a 401 clears the in-memory token, emits `session_expired` and disables retry until a new session is configured; terminal messages remain visible and active searches are not falsely failed on transient errors; stale proposal accept/decline conflicts now automatically schedule an authoritative proposal recovery instead of requiring each UI caller to implement 409 handling | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; the real two-player proposal smoke now exercises the client-owned conflict-recovery path. **This row's own "remain" list was stale**: `matchmaking.gd`'s decline button/handler already existed (`%DeclineButton`, `_on_decline_pressed`, visibility toggled by `MatchmakingState.PROPOSED` alongside accept) with no test gap; `ControlPlaneClient.can_retry_last_mutation()`/`retry_last_mutation()` -- the generic "duplicate-action recovery beyond proposals" and "regional outage retry" mechanism -- also already existed (any mutation, not just a proposal response, becomes retryable on a transport failure or 503/408/429, and the matchmaking queue button already fell back to it), it simply had zero test coverage proving the transition actually happens for a non-proposal mutation; two new tests close that (`test_generic_mutation_retry_recovers_after_a_transient_failure`, `test_generic_mutation_retry_is_not_offered_for_unsafe_failures`). `MatchmakingClient`'s real dispatch (`HTTPRequest.request()`) needs a live SceneTree that `test_runner.tscn`'s synchronous single-`_ready()` execution model cannot provide mid-suite, so the two new tests exercise the `can_retry_last_mutation()` decision boundary and the `ERR_INVALID_DATA` fail-closed path rather than the literal network call; verified against the real Godot 4.7.1 binary (214/214 tests, no crash, no engine-level error), plus a full `make verify-multiplayer-local` re-run. Version-mismatch-specific messaging (a protocol rejection currently surfaces only as the server's generic error string, not a distinguished "update your client" affordance), failed-reconnect UX, and arena selection/long-running worker integration (§8.16) remain | +| 8.43 `[D:8.39,8.40,8.41]` | **IN PROGRESS.** Matchmaking client now distinguishes expired queue recovery, session expiry, missing records and retryable control-plane outages; a 401 clears the in-memory token, emits `session_expired` and disables retry until a new session is configured; terminal messages remain visible and active searches are not falsely failed on transient errors; stale proposal accept/decline conflicts now automatically schedule an authoritative proposal recovery instead of requiring each UI caller to implement 409 handling | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; the real two-player proposal smoke now exercises the client-owned conflict-recovery path. **This row's own "remain" list was stale**: `matchmaking.gd`'s decline button/handler already existed (`%DeclineButton`, `_on_decline_pressed`, visibility toggled by `MatchmakingState.PROPOSED` alongside accept) with no test gap; `ControlPlaneClient.can_retry_last_mutation()`/`retry_last_mutation()` -- the generic "duplicate-action recovery beyond proposals" and "regional outage retry" mechanism -- also already existed (any mutation, not just a proposal response, becomes retryable on a transport failure or 503/408/429, and the matchmaking queue button already fell back to it), it simply had zero test coverage proving the transition actually happens for a non-proposal mutation; two new tests close that (`test_generic_mutation_retry_recovers_after_a_transient_failure`, `test_generic_mutation_retry_is_not_offered_for_unsafe_failures`). `MatchmakingClient`'s real dispatch (`HTTPRequest.request()`) needs a live SceneTree that `test_runner.tscn`'s synchronous single-`_ready()` execution model cannot provide mid-suite, so the two new tests exercise the `can_retry_last_mutation()` decision boundary and the `ERR_INVALID_DATA` fail-closed path rather than the literal network call; verified against the real Godot 4.7.1 binary (214/214 tests, no crash, no engine-level error), plus a full `make verify-multiplayer-local` re-run. **Version-mismatch messaging is now built**: before this, there was no server-side protocol rejection at all -- `queue_create` accepted any `protocol_version >= 1` unconditionally, so an outdated client could only ever discover the mismatch by waiting forever unmatched (the matcher's own compatibility check requires every formed player to share an identical `protocol_version`), with no error and no explanation. `Service.MinProtocolVersion` (opt-in, zero by default) now rejects a below-floor `queue_create` with `426 Upgrade Required`/`client_outdated` before ever reaching the candidate provider, wired via `cmd/control-plane`'s `--min-protocol-version` flag; `ControlPlaneClient` recognises 426 on `queue_create` specifically and sets a distinct "Your client is out of date -- please update to continue searching" message, clearing `_last_queue_create` so the generally-available "Retry Search" affordance is never offered for a failure retrying can't fix. `TestQueueCreateEnforcesMinProtocolVersion`/`TestQueueCreateMinProtocolVersionZeroIsDisabled` (Go) and `test_outdated_client_receives_a_distinct_message_and_no_retry_offer` (Godot) cover the floor end to end: below-floor rejection before the candidate provider is ever reached, exactly-at-floor acceptance, the opt-in zero-disables-it default, the client message and the suppressed retry. Failed-reconnect UX and long-running worker integration (§8.16) remain | #### 8F — Observability, verification, cost and rollout diff --git a/server/api/service.go b/server/api/service.go index c5b6acaf..361a459c 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -136,6 +136,15 @@ type Service struct { ClientIPs *ClientIPResolver Admission AdmissionController ReadinessCheck ReadinessCheck + // MinProtocolVersion, when positive, is the floor below which queue_create + // is refused outright with 426 Upgrade Required rather than silently + // queueing a client the matcher can never actually pair with anyone (its + // own compatibility check requires every formed player to share an + // identical protocol_version -- an outdated client below every other + // player's version would otherwise wait forever with no explanation). + // Zero (the default) disables the floor entirely, preserving the prior + // permissive behavior for callers that never set it. + MinProtocolVersion int // Log receives a credential-safe structured event for lifecycle-relevant // reads and mutations. Nil // is a valid, silent no-op -- every call site must stay optional so @@ -414,6 +423,11 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request") return } + if s.MinProtocolVersion > 0 && input.ProtocolVersion < s.MinProtocolVersion { + s.logEvent(observability.Event{Event: "queue_create", QueueID: input.TicketID, Stage: "outdated_client", OccurredAt: s.now(), Fields: map[string]any{"protocol_version": input.ProtocolVersion, "min_protocol_version": s.MinProtocolVersion}}) + writeError(w, http.StatusUpgradeRequired, "client_outdated") + return + } key := r.Header.Get("Idempotency-Key") if len(key) < 16 || len(key) > 128 { writeError(w, http.StatusBadRequest, "invalid_idempotency_key") diff --git a/server/api/service_test.go b/server/api/service_test.go index 4c4dfdf2..f92c837d 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -767,6 +767,98 @@ func TestQueueCreateRequiresCompatibilityMetadataAndPassesItToProvider(t *testin } } +// TestQueueCreateEnforcesMinProtocolVersion covers the gap multiplayer-next.md +// 8.43 named "version-mismatch-specific client messaging": before this, +// queue_create accepted any protocol_version >= 1 unconditionally, so an +// outdated client below every other queued player's version would simply +// queue forever with no error at all -- the matcher's own compatibility +// check requires every formed player to share an identical protocol_version, +// so it could never be paired, and nothing ever told it why. +func TestQueueCreateEnforcesMinProtocolVersion(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + session, token, err := sessions.Issue("player-1", time.Hour, now) + if err != nil { + t.Fatal(err) + } + calls := 0 + service := &Service{ + Sessions: sessions, + Queue: domain.NewQueue(), + Now: func() time.Time { return now }, + MinProtocolVersion: 5, + CandidateV2: func(_ string, ticketID string, spec domain.QueueSpec) (domain.Candidate, error) { + calls++ + return domain.Candidate{PlayerID: "player-1", TicketID: ticketID, Playlist: spec.Playlist, ClientBuild: spec.ClientBuild, ProtocolVersion: spec.ProtocolVersion, EnqueuedAt: now}, nil + }, + } + server := httptest.NewServer(service.Handler()) + defer server.Close() + request := func(body string) (*http.Response, string) { + req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + req.Header.Set("Idempotency-Key", "create-key-123456") + response, requestErr := http.DefaultClient.Do(req) + if requestErr != nil { + t.Fatal(requestErr) + } + decoded, _ := io.ReadAll(response.Body) + response.Body.Close() + return response, string(decoded) + } + response, body := request(`{"ticket_id":"ticket-1","playlist":"casual","client_build":"build-1","protocol_version":4}`) + if response.StatusCode != http.StatusUpgradeRequired { + t.Fatalf("below-floor status = %d, want 426 Upgrade Required; body=%s", response.StatusCode, body) + } + if !strings.Contains(body, "client_outdated") { + t.Fatalf("below-floor body does not name the outdated-client error: %s", body) + } + if calls != 0 { + t.Fatalf("candidate provider must not be reached for a rejected below-floor request, calls=%d", calls) + } + response, _ = request(`{"ticket_id":"ticket-1","playlist":"casual","client_build":"build-1","protocol_version":5}`) + if response.StatusCode != http.StatusCreated { + t.Fatalf("exactly-at-floor status = %d, want 201", response.StatusCode) + } + if calls != 1 { + t.Fatalf("exactly-at-floor request should reach the provider once, calls=%d", calls) + } +} + +// TestQueueCreateMinProtocolVersionZeroIsDisabled proves the floor is opt-in: +// every existing Service literal across the codebase that never sets +// MinProtocolVersion must keep accepting protocol_version 1 exactly as +// before, unconditionally. +func TestQueueCreateMinProtocolVersionZeroIsDisabled(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + session, token, err := sessions.Issue("player-1", time.Hour, now) + if err != nil { + t.Fatal(err) + } + service := &Service{ + Sessions: sessions, + Queue: domain.NewQueue(), + Now: func() time.Time { return now }, + CandidateV2: func(_ string, ticketID string, spec domain.QueueSpec) (domain.Candidate, error) { + return domain.Candidate{PlayerID: "player-1", TicketID: ticketID, Playlist: spec.Playlist, ClientBuild: spec.ClientBuild, ProtocolVersion: spec.ProtocolVersion, EnqueuedAt: now}, nil + }, + } + server := httptest.NewServer(service.Handler()) + defer server.Close() + req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-1","playlist":"casual","client_build":"build-1","protocol_version":1}`)) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + req.Header.Set("Idempotency-Key", "create-key-123456") + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201 with MinProtocolVersion left at its zero default", response.StatusCode) + } +} + func TestQueueCreateRejectsCandidateMetadataMismatch(t *testing.T) { now := time.Unix(1000, 0).UTC() sessions := domain.NewSessionStore() diff --git a/server/cmd/control-plane/main.go b/server/cmd/control-plane/main.go index 3f44eb37..9c43c28f 100644 --- a/server/cmd/control-plane/main.go +++ b/server/cmd/control-plane/main.go @@ -34,6 +34,7 @@ func main() { rateWindow := flag.Duration("rate-limit-window", time.Minute, "fixed window for the per-replica request limiter") rateMaxKeys := flag.Int("rate-limit-max-keys", 10000, "maximum credential/IP keys retained by the per-replica request limiter") trustedProxyCIDRs := flag.String("trusted-proxy-cidrs", os.Getenv("COSMIC_CLASH_TRUSTED_PROXY_CIDRS"), "comma-separated immediate proxy CIDRs allowed to supply X-Forwarded-For") + minProtocolVersion := flag.Int("min-protocol-version", 0, "reject queue_create below this protocol_version with 426 Upgrade Required instead of queueing a client the matcher can never pair with anyone; zero disables the floor") flag.Parse() if *role != "api" { fatalf("unsupported role %q (only api is implemented)", *role) @@ -44,6 +45,9 @@ func main() { if *redisTTL <= 0 { fatalf("--redis-ttl must be positive") } + if *minProtocolVersion < 0 { + fatalf("--min-protocol-version must be non-negative") + } rateLimiter, err := api.NewRateLimiter(*rateLimit, *rateWindow, *rateMaxKeys) if err != nil { fatalf("invalid request limiter configuration: %v", err) @@ -78,6 +82,7 @@ func main() { service := newAPIService(db, *workloadSecret, candidateIndex) service.RateLimiter = rateLimiter service.ClientIPs = clientIPs + service.MinProtocolVersion = *minProtocolVersion admission := api.NewAdmissionGate(*degraded) service.Admission = admission server := &http.Server{Addr: *listen, Handler: service.Handler(), ReadHeaderTimeout: 5 * time.Second}