feat(gateway,ui): client-version gate — turn away too-old builds
Introduce a minimum-supported-client gate so a future incompatible wire change can turn away installed builds too old to speak it, cleanly, instead of letting them crash on decode. It rides the outermost stable layer (an HTTP header), never the FlatBuffers payload. Gateway: - New internal/clientver: dependency-free parse + compare of the leading MAJOR.MINOR.PATCH (a git-describe suffix is tolerated). - GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load). - connectsrv checks the X-Client-Version header before decoding the payload: Execute returns result_code="update_required" (before the registry lookup), Subscribe returns FailedPrecondition. It fails open on an absent or garbled header — the header is a client-controlled compatibility signal, not an access control. Client: - Attach X-Client-Version on every call. - A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the update_required sentinel; a mock __update hook drives the e2e. Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK / Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate tests, retry.test.ts, Playwright update.spec.ts.
This commit is contained in:
@@ -12,6 +12,7 @@ var (
|
||||
errInternal = errors.New("internal error")
|
||||
errMissingToken = errors.New("missing session token")
|
||||
errInvalidSession = errors.New("invalid or expired session")
|
||||
errUpdateRequired = errors.New("client too old, update required")
|
||||
)
|
||||
|
||||
// errUnknownMessageType reports an unregistered message type.
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/clientver"
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/push"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
@@ -46,6 +47,16 @@ const heartbeatKind = "heartbeat"
|
||||
// spoofer, so trusting it is safe.
|
||||
const honeypotHeader = "X-Scrabble-Honeypot"
|
||||
|
||||
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
|
||||
// the edge can turn away a build too old to speak the current wire contract, before it decodes
|
||||
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
|
||||
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
|
||||
// "you must update". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2).
|
||||
const (
|
||||
clientVersionHeader = "X-Client-Version"
|
||||
resultUpdateRequired = "update_required"
|
||||
)
|
||||
|
||||
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||
// class field of the periodic rejection report.
|
||||
const (
|
||||
@@ -88,6 +99,11 @@ type Server struct {
|
||||
|
||||
maxBodyBytes int
|
||||
|
||||
// minClient is the lowest client version served; gateOn is false when the gate is dormant
|
||||
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
|
||||
minClient clientver.Version
|
||||
gateOn bool
|
||||
|
||||
publicPolicy ratelimit.Policy
|
||||
userPolicy ratelimit.Policy
|
||||
emailPolicy ratelimit.Policy
|
||||
@@ -128,6 +144,10 @@ type Deps struct {
|
||||
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
||||
// zero or negative selects config.DefaultMaxBodyBytes.
|
||||
MaxBodyBytes int
|
||||
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
|
||||
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
|
||||
// the gate dormant.
|
||||
MinClientVersion string
|
||||
}
|
||||
|
||||
// NewServer constructs the edge service.
|
||||
@@ -160,6 +180,18 @@ func NewServer(d Deps) *Server {
|
||||
if rl == (config.RateLimitConfig{}) {
|
||||
rl = config.DefaultRateLimit()
|
||||
}
|
||||
// Parse the minimum client version once. Config.validate already rejects an unparseable
|
||||
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
|
||||
// the gate dormant.
|
||||
var minClient clientver.Version
|
||||
gateOn := false
|
||||
if d.MinClientVersion != "" {
|
||||
if v, ok := clientver.Parse(d.MinClientVersion); ok {
|
||||
minClient, gateOn = v, true
|
||||
} else {
|
||||
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
|
||||
}
|
||||
}
|
||||
return &Server{
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
@@ -176,6 +208,8 @@ func NewServer(d Deps) *Server {
|
||||
adminProxy: d.AdminProxy,
|
||||
metrics: newServerMetrics(d.Meter, blocklist),
|
||||
maxBodyBytes: maxBody,
|
||||
minClient: minClient,
|
||||
gateOn: gateOn,
|
||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
||||
@@ -285,6 +319,22 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// clientTooOld reports whether the X-Client-Version header names a version below the configured
|
||||
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
|
||||
// false. The header is a client-controlled compatibility signal, not an access control (it is
|
||||
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
|
||||
// non-browser caller — must never be blocked spuriously.
|
||||
func (s *Server) clientTooOld(header string) bool {
|
||||
if !s.gateOn {
|
||||
return false
|
||||
}
|
||||
v, ok := clientver.Parse(header)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return clientver.Less(v, s.minClient)
|
||||
}
|
||||
|
||||
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||
// session, unknown type, internal) become Connect errors.
|
||||
@@ -294,6 +344,17 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
result := "internal"
|
||||
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
||||
|
||||
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
|
||||
// the payload is decoded, so a too-old client makes zero successful calls but sees the
|
||||
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
|
||||
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
||||
result = resultUpdateRequired
|
||||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||
RequestId: req.Msg.GetRequestId(),
|
||||
ResultCode: resultUpdateRequired,
|
||||
}), nil
|
||||
}
|
||||
|
||||
op, ok := s.registry.Lookup(msgType)
|
||||
if !ok {
|
||||
result = "unknown_type"
|
||||
@@ -363,6 +424,11 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||
// heartbeat until the client disconnects.
|
||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
||||
// A stream carries no result_code, so a too-old client is refused with the Connect
|
||||
// FailedPrecondition counterpart of the update_required sentinel.
|
||||
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
||||
return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
|
||||
}
|
||||
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -22,8 +22,13 @@ import (
|
||||
)
|
||||
|
||||
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
|
||||
// client plus a cleanup func.
|
||||
// client plus a cleanup func. The client-version gate is off.
|
||||
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||
return newEdgeMin(t, "", backendHandler)
|
||||
}
|
||||
|
||||
// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
|
||||
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||
t.Helper()
|
||||
backendSrv := httptest.NewServer(backendHandler)
|
||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||
@@ -31,12 +36,13 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: transcode.NewRegistry(backend, nil),
|
||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||
Limiter: ratelimit.New(),
|
||||
Hub: push.NewHub(0),
|
||||
RateLimit: config.DefaultRateLimit(),
|
||||
Heartbeat: 15 * time.Second,
|
||||
Registry: transcode.NewRegistry(backend, nil),
|
||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||
Limiter: ratelimit.New(),
|
||||
Hub: push.NewHub(0),
|
||||
RateLimit: config.DefaultRateLimit(),
|
||||
Heartbeat: 15 * time.Second,
|
||||
MinClientVersion: minVersion,
|
||||
})
|
||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||
@@ -108,6 +114,84 @@ func TestExecuteGuestGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
|
||||
// than the configured minimum is turned away with the update_required result code before the
|
||||
// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
|
||||
// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
|
||||
func TestExecuteVersionGate(t *testing.T) {
|
||||
// The backend answers auth.guest with a session; a gated call never reaches it.
|
||||
backend := func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
min string
|
||||
header string
|
||||
msgType string
|
||||
want string
|
||||
}{
|
||||
{"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
|
||||
{"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
|
||||
{"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
|
||||
{"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
|
||||
{"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
|
||||
{"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, cleanup := newEdgeMin(t, tc.min, backend)
|
||||
defer cleanup()
|
||||
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"})
|
||||
if tc.header != "" {
|
||||
req.Header().Set("X-Client-Version", tc.header)
|
||||
}
|
||||
resp, err := client.Execute(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got := resp.Msg.GetResultCode(); got != tc.want {
|
||||
t.Fatalf("result_code = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
|
||||
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
|
||||
// fails Unauthenticated with no session, proving it passed the version gate).
|
||||
func TestSubscribeVersionGate(t *testing.T) {
|
||||
noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
|
||||
tests := []struct {
|
||||
name string
|
||||
min string
|
||||
header string
|
||||
want connect.Code
|
||||
}{
|
||||
{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
|
||||
{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
|
||||
{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, cleanup := newEdgeMin(t, tc.min, noBackend)
|
||||
defer cleanup()
|
||||
req := connect.NewRequest(&edgev1.SubscribeRequest{})
|
||||
if tc.header != "" {
|
||||
req.Header().Set("X-Client-Version", tc.header)
|
||||
}
|
||||
stream, err := client.Subscribe(context.Background(), req)
|
||||
if err == nil {
|
||||
for stream.Receive() {
|
||||
}
|
||||
err = stream.Err()
|
||||
}
|
||||
if got := connect.CodeOf(err); got != tc.want {
|
||||
t.Fatalf("code = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("backend must not be called without a session")
|
||||
|
||||
Reference in New Issue
Block a user