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:
max bittkerandClaude Opus 5.5 committed 2026-10-04 01:07:37 -04:00
1 parent ca34325e7a
commit 2fdb019b3e
7 files changed
+121 -13

No files matched your search

+3
View File
@@ -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
View File
@@ -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"
+7 -6
View File
@@ -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>([
+30
View File
@@ -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();
});
});
+12 -6
View File
@@ -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
});
}
}
}
+41
View File
@@ -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);
});
});
+27
View File
@@ -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;
}
}