mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
fix(server): cap game sessions at 100 per IP keyed on Fly-Client-IP; skip repeat state frames
MAX_PLAYERS_PER_IP is now 100 on Fly (code default stays 200 for local load tests). getIp only trusts Fly-Client-IP behind Fly and the socket peer elsewhere; the old cf-connecting-ip / X-Forwarded-For lookup took the first, client-supplied entry, so a swarm could claim a new IP per connection and walk past the cap. The gateway no longer fans out a state frame that repeats the last one sent to subscribers apart from its publication revision (same tick, same content: a post-action publish where the action changed nothing visible yet). Subscribers get the next tick's frame instead; any difference still goes out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca34325e7a
commit
2fdb019b3e
7 files changed
+121
-13
No files matched your search
@@ -26,6 +26,9 @@ WEBSITE_REGISTRATION = "false"
|
||||
WEB_PORT = "8080"
|
||||
# Gateway's direct listener (see the 7443 service below).
|
||||
GATEWAY_PUBLIC_PORT = "7781"
|
||||
# Concurrent game sessions per source IP (code default 200). Keyed on
|
||||
# Fly-Client-IP, which clients can't spoof.
|
||||
MAX_PLAYERS_PER_IP = "100"
|
||||
|
||||
[http_service]
|
||||
auto_start_machines = true
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"docs:api": "bun sdk/generate-api-docs.ts",
|
||||
"docs:api:check": "bun sdk/generate-api-docs.ts --check",
|
||||
"fetch-collision-data": "bun sdk/fetch-collision-data.ts",
|
||||
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts",
|
||||
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/gateway/state-fanout.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json",
|
||||
"test:market:integration": "cd server/engine && bun test/market-integration.ts && bun test/market-recovery.ts && bun test/market-protocol.ts && bun test/market-disabled.ts"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import path from 'path';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export function getIp(req: Request): string | null {
|
||||
const forwardedFor = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for');
|
||||
if (!forwardedFor) {
|
||||
return null;
|
||||
}
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
export function getIp(req: Request, behindFlyProxy = Boolean(process.env.FLY_APP_NAME)): string | null {
|
||||
// Only Fly's ingress can vouch for this header. Outside Fly, callers fall
|
||||
// back to the socket peer; arbitrary forwarded headers are never trusted.
|
||||
if (!behindFlyProxy) return null;
|
||||
const ip = req.headers.get('fly-client-ip')?.trim();
|
||||
return ip && isIP(ip) ? ip : null;
|
||||
}
|
||||
|
||||
export const MIME_TYPES = new Map<string, string>([
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// The per-IP login cap (World.PLAYER_MAX_PER_IP) keys on getIp. Only Fly's
|
||||
// ingress header may set it: client-supplied forwarding headers would let a
|
||||
// swarm claim a fresh IP per connection and walk past the cap.
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getIp } from '../src/web/utils.ts';
|
||||
|
||||
const req = (headers: Record<string, string>) => new Request('http://game/bot', { headers });
|
||||
|
||||
describe('getIp', () => {
|
||||
test('uses Fly-Client-IP behind Fly', () => {
|
||||
expect(getIp(req({ 'fly-client-ip': '203.0.113.9' }), true)).toBe('203.0.113.9');
|
||||
expect(getIp(req({ 'fly-client-ip': '2a09:bac1:5500::3e3:2c' }), true)).toBe('2a09:bac1:5500::3e3:2c');
|
||||
});
|
||||
|
||||
test('ignores client-supplied forwarding headers', () => {
|
||||
const spoofed = req({ 'fly-client-ip': '203.0.113.9', 'x-forwarded-for': '198.51.100.1, 203.0.113.9', 'cf-connecting-ip': '198.51.100.2' });
|
||||
expect(getIp(spoofed, true)).toBe('203.0.113.9');
|
||||
expect(getIp(req({ 'x-forwarded-for': '198.51.100.1' }), true)).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a malformed header', () => {
|
||||
expect(getIp(req({ 'fly-client-ip': 'not-an-ip' }), true)).toBeNull();
|
||||
});
|
||||
|
||||
test('outside Fly, defers to the socket peer', () => {
|
||||
expect(getIp(req({ 'fly-client-ip': '203.0.113.9' }), false)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { PLAYER_CHAT_TYPES } from './types';
|
||||
import { ChatHistory } from '../../sdk/chat-history';
|
||||
import { chunkMessage } from '../../sdk/chunking';
|
||||
import { repeatsLastFanout } from './state-fanout';
|
||||
|
||||
const GATEWAY_PORT = parseInt(process.env.AGENT_PORT || '7780');
|
||||
// Bind address. Fleet hosts running a private gateway set 127.0.0.1 so only
|
||||
@@ -139,6 +140,7 @@ interface BotSession {
|
||||
connectedAt: number; // When bot first connected (timestamp)
|
||||
lastHeartbeat: number; // Last message received (any type)
|
||||
maxMessageLength?: number; // Server-configured chat cap, relayed from the bot page to SDKs
|
||||
lastFanoutState?: BotWorldState; // Last state actually sent to SDK subscribers
|
||||
}
|
||||
|
||||
// Session status for diagnostics
|
||||
@@ -416,12 +418,16 @@ const SyncModule = {
|
||||
if (message.state.gameMessages?.length) {
|
||||
chatHistoryFor(session.username).record(message.state.gameMessages);
|
||||
}
|
||||
for (const sdkSession of this.getSDKSessionsForBot(session.username)) {
|
||||
this.sendToSDK(sdkSession, {
|
||||
type: 'sdk_state',
|
||||
state: message.state,
|
||||
stateReceivedAt: session.lastStateReceivedAt
|
||||
});
|
||||
const subscribers = this.getSDKSessionsForBot(session.username);
|
||||
if (subscribers.length > 0 && !repeatsLastFanout(session.lastFanoutState ?? null, message.state)) {
|
||||
session.lastFanoutState = message.state;
|
||||
for (const sdkSession of subscribers) {
|
||||
this.sendToSDK(sdkSession, {
|
||||
type: 'sdk_state',
|
||||
state: message.state,
|
||||
stateReceivedAt: session.lastStateReceivedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { repeatsLastFanout } from './state-fanout';
|
||||
import type { BotWorldState } from './types';
|
||||
|
||||
function state(tick: number, revision: number, extra: Record<string, unknown> = {}): BotWorldState {
|
||||
return { tick, revision, inGame: true, player: { name: 'bot', worldX: 3222, worldZ: 3218 }, ...extra } as unknown as BotWorldState;
|
||||
}
|
||||
|
||||
describe('repeatsLastFanout', () => {
|
||||
test('a post-action frame identical except for its revision is a repeat', () => {
|
||||
expect(repeatsLastFanout(state(10, 5), state(10, 6))).toBe(true);
|
||||
});
|
||||
|
||||
test('any content difference goes out', () => {
|
||||
expect(repeatsLastFanout(state(10, 5), state(10, 6, { inventory: [{ id: 1 }] }))).toBe(false);
|
||||
});
|
||||
|
||||
test('a new tick always goes out, even with identical content', () => {
|
||||
expect(repeatsLastFanout(state(10, 5), state(11, 6))).toBe(false);
|
||||
});
|
||||
|
||||
test('nothing to compare against goes out', () => {
|
||||
expect(repeatsLastFanout(null, state(10, 5))).toBe(false);
|
||||
});
|
||||
|
||||
test('comparison leaves both revisions intact', () => {
|
||||
const previous = state(10, 5);
|
||||
const next = state(10, 6);
|
||||
repeatsLastFanout(previous, next);
|
||||
expect((previous as any).revision).toBe(5);
|
||||
expect((next as any).revision).toBe(6);
|
||||
});
|
||||
|
||||
test('frames without a revision compare on content alone', () => {
|
||||
const a = { tick: 3, inGame: true } as unknown as BotWorldState;
|
||||
const b = { tick: 3, inGame: true } as unknown as BotWorldState;
|
||||
expect(repeatsLastFanout(a, b)).toBe(true);
|
||||
expect('revision' in b).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Whether a bot's state frame can skip the fan-out to its SDK subscribers.
|
||||
//
|
||||
// Bots publish once per game tick and again right after each action result.
|
||||
// When the action hasn't changed anything visible yet, that second frame
|
||||
// repeats the first except for its publication revision. Subscribers learn
|
||||
// nothing from it and get the next tick's frame instead (at most one tick
|
||||
// later). Frames from a new tick, or with any difference at all, always go out.
|
||||
|
||||
import type { BotWorldState } from './types';
|
||||
|
||||
type RevisionedState = BotWorldState & { revision?: number };
|
||||
|
||||
export function repeatsLastFanout(previous: BotWorldState | null, next: BotWorldState): boolean {
|
||||
if (!previous || previous === next || previous.tick !== next.tick) return false;
|
||||
return jsonIgnoringRevision(previous) === jsonIgnoringRevision(next);
|
||||
}
|
||||
|
||||
function jsonIgnoringRevision(state: RevisionedState): string {
|
||||
if (!('revision' in state)) return JSON.stringify(state);
|
||||
const revision = state.revision;
|
||||
state.revision = 0;
|
||||
try {
|
||||
return JSON.stringify(state);
|
||||
} finally {
|
||||
state.revision = revision;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user