feat: expose validated assignment endpoints

This commit is contained in:
Josh Creek
2026-09-01 08:17:39 +01:00
parent 0cdb60c0d9
commit 467c25b20c
7 changed files with 51 additions and 8 deletions
+15 -1
View File
@@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strconv"
"strings"
@@ -58,6 +59,7 @@ type AssignmentView struct {
ProtocolVersion int `json:"protocol_version"`
Transport string `json:"transport"`
JoinAuthorisation string `json:"join_authorisation"`
Endpoint string `json:"endpoint"`
// Revision is routing metadata for the event stream, not part of the v1
// assignment response. Keeping it alongside the durable view prevents the
// REST recovery boundary from emitting a synthetic revision zero.
@@ -509,7 +511,7 @@ func (s *Service) assignment(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found")
return
}
if view.ServerID == "" || view.Slot < 0 || view.Slot > 5 || view.ProtocolVersion < 1 || (view.Transport != "enet" && view.Transport != "steam_sdr") || view.JoinAuthorisation == "" || view.ExpiresAt.IsZero() || !now.Before(view.ExpiresAt) {
if view.ServerID == "" || view.Slot < 0 || view.Slot > 5 || view.ProtocolVersion < 1 || (view.Transport != "enet" && view.Transport != "steam_sdr") || view.JoinAuthorisation == "" || !validAssignmentEndpoint(view.Endpoint) || view.ExpiresAt.IsZero() || !now.Before(view.ExpiresAt) {
writeError(w, http.StatusServiceUnavailable, "assignment_unavailable")
return
}
@@ -517,6 +519,18 @@ func (s *Service) assignment(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, view)
}
func validAssignmentEndpoint(endpoint string) bool {
if endpoint == "" || strings.ContainsAny(endpoint, "/?#") {
return false
}
host, portText, err := net.SplitHostPort(endpoint)
if err != nil || host == "" {
return false
}
port, err := strconv.Atoi(portText)
return err == nil && port >= 1 && port <= 65535
}
func assignmentChangedEvent(view AssignmentView, now time.Time) ControlPlaneEvent {
return ControlPlaneEvent{Event: "assignment_changed", Revision: view.Revision, ResourceID: view.MatchID, OccurredAt: now, MatchID: view.MatchID, ServerID: view.ServerID, PlayerID: view.PlayerID}
}
+14 -1
View File
@@ -939,7 +939,7 @@ func TestAssignmentRecoveryIsPlayerScopedAndRejectsExpiredOrMismatchedViews(t *t
}
current := now
service := &Service{Sessions: sessions, Now: func() time.Time { return current }, Assignment: func(_ context.Context, _ string, matchID string, _ time.Time) (AssignmentView, error) {
return AssignmentView{MatchID: matchID, ServerID: "server-1", PlayerID: "player-a", Slot: 2, ExpiresAt: now.Add(time.Minute), ProtocolVersion: 1, Transport: "enet", JoinAuthorisation: "signed-join"}, nil
return AssignmentView{MatchID: matchID, ServerID: "server-1", PlayerID: "player-a", Slot: 2, ExpiresAt: now.Add(time.Minute), ProtocolVersion: 1, Transport: "enet", Endpoint: "127.0.0.1:30001", JoinAuthorisation: "signed-join"}, nil
}}
server := httptest.NewServer(service.Handler())
defer server.Close()
@@ -999,3 +999,16 @@ func TestAssignmentEventUsesAuthoritativeRevisionWithoutChangingResponseShape(t
t.Fatalf("assignment response leaked event revision: %s", payload)
}
}
func TestAssignmentEndpointValidationRejectsAmbiguousOrUnsafeEndpoints(t *testing.T) {
for _, endpoint := range []string{"", "127.0.0.1", "127.0.0.1:0", "127.0.0.1:70000", "https://127.0.0.1:1", "127.0.0.1:1/path"} {
if validAssignmentEndpoint(endpoint) {
t.Fatalf("unsafe endpoint accepted: %q", endpoint)
}
}
for _, endpoint := range []string{"127.0.0.1:1", "example.invalid:65535", "[2001:db8::1]:31001"} {
if !validAssignmentEndpoint(endpoint) {
t.Fatalf("valid endpoint rejected: %q", endpoint)
}
}
}
+1
View File
@@ -27,6 +27,7 @@ func AssignmentProviderFromStore(db *sql.DB) AssignmentProvider {
ProtocolVersion: assignment.ProtocolVersion,
Transport: assignment.Transport,
JoinAuthorisation: assignment.JoinAuthorisation,
Endpoint: assignment.Endpoint,
Revision: assignment.Revision,
}, nil
}
+1 -1
View File
@@ -81,7 +81,7 @@
"QueueTicket": {"type": "object", "required": ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"], "additionalProperties": false, "properties": {"ticket_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "playlist": {"type": "string", "enum": ["casual", "ranked"]}, "state": {"$ref": "#/components/schemas/QueueState"}, "revision": {"type": "integer", "minimum": 0}, "enqueued_at": {"type": "string", "format": "date-time"}, "expires_at": {"type": "string", "format": "date-time"}}},
"QueueState": {"type": "string", "enum": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]},
"Proposal": {"type": "object", "required": ["proposal_id", "revision", "state", "expires_at", "participants"], "additionalProperties": false, "properties": {"proposal_id": {"$ref": "#/components/schemas/OpaqueId"}, "revision": {"type": "integer", "minimum": 0}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}, "expires_at": {"type": "string", "format": "date-time"}, "participants": {"type": "array", "minItems": 2, "items": {"$ref": "#/components/schemas/OpaqueId"}}}},
"Assignment": {"type": "object", "required": ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "join_authorisation"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "server_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "slot": {"type": "integer", "minimum": 0, "maximum": 5}, "expires_at": {"type": "string", "format": "date-time"}, "protocol_version": {"type": "integer", "minimum": 1}, "transport": {"type": "string", "enum": ["steam_sdr", "enet"]}, "join_authorisation": {"type": "string"}}},
"Assignment": {"type": "object", "required": ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "endpoint", "join_authorisation"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "server_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "slot": {"type": "integer", "minimum": 0, "maximum": 5}, "expires_at": {"type": "string", "format": "date-time"}, "protocol_version": {"type": "integer", "minimum": 1}, "transport": {"type": "string", "enum": ["steam_sdr", "enet"]}, "endpoint": {"type": "string", "minLength": 3, "maxLength": 256}, "join_authorisation": {"type": "string"}}},
"ServerRegistration": {"type": "object", "required": ["match_id", "protocol_version", "image_digest", "assignment_ready"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "protocol_version": {"type": "integer", "minimum": 1}, "image_digest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, "assignment_ready": {"type": "boolean"}}},
"MatchResult": {"type": "object", "required": ["match_id", "result_nonce", "score", "integrity_state"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "result_nonce": {"type": "string", "minLength": 16, "maxLength": 128}, "score": {"type": "object", "required": ["team_0", "team_1"], "additionalProperties": false, "properties": {"team_0": {"type": "integer", "minimum": 0}, "team_1": {"type": "integer", "minimum": 0}}}, "integrity_state": {"type": "string", "enum": ["CERTIFIED", "SUPPRESSED", "REVIEW"]}}}
}