Skip to content

Commit 0162756

Browse files
committed
add rate limiting
1 parent 3c7f488 commit 0162756

File tree

10 files changed

+163
-25
lines changed

10 files changed

+163
-25
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Eaglercraft relay server implementation written in TypeScript
1111
- [x] Origin whitelist
1212
- [x] Join code customization
1313
- [x] STUN / TURN server support
14-
- [ ] ~~Rate limiting~~
14+
- [x] Rate limiting
1515

1616
## Usage:
1717

package-lock.json

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
{
22
"name": "eaglerrelayjs",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Eaglercraft relay server implementation in typescript",
55
"repository": "github:WebMCDevelopment/EaglerRelayJS",
66
"type": "module",
77
"main": "dist/index.js",
88
"scripts": {
9+
"start": "tsx src/index.ts",
910
"build": "tsc && tsc-esm-fix dist",
1011
"lint": "eslint . --ext .ts",
1112
"lint:fix": "eslint . --ext .ts --fix"

src/server/EaglerSPClient.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ export class EaglerSPClient {
1414
private static readonly CLIENT_CODE_LENGTH = 16;
1515
private static readonly CLIENT_CODE_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
1616

17-
public SOCKET: WebSocket;
18-
public SERVER: EaglerSPServer;
19-
public ID: string;
20-
public ADDRESS: string;
21-
public CREATED: number;
17+
public readonly SOCKET: WebSocket;
18+
public readonly SERVER: EaglerSPServer;
19+
public readonly ID: string;
20+
public readonly ADDRESS: string;
21+
public readonly CREATED: number;
22+
2223
public STATE: LoginState;
2324
public SERVER_NOTIFIED_OF_CLOSE: boolean;
2425

src/server/EaglerSPRelay.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@ import { RelayConfig } from '../utils/RelayConfig';
33
import { IncomingMessage } from 'http';
44
import { Socket } from 'net';
55
import { RelayLogger } from '../utils/RelayLogger';
6-
import { RelayPacket } from '../pkt/RelayPacket';
7-
import { RelayPacket00Handshake } from '../pkt/RelayPacket00Handshake';
8-
import { RelayPacketFFErrorCode } from '../pkt/RelayPacketFFErrorCode';
96
import { EaglerSPClient } from './EaglerSPClient';
107
import { EaglerSPServer } from './EaglerSPServer';
8+
import { RelayPacket } from '../pkt/RelayPacket';
119
import { RelayPacket01ICEServers } from '../pkt/RelayPacket01ICEServers';
10+
import { RelayPacket00Handshake } from '../pkt/RelayPacket00Handshake';
11+
import { LocalWorld, RelayPacket07LocalWorlds } from '../pkt/RelayPacket07LocalWorlds';
1212
import { RelayPacket69Pong } from '../pkt/RelayPacket69Pong';
13+
import { RelayPacketFEDisconnectClient } from '../pkt/RelayPacketFEDisconnectClient';
14+
import { RelayPacketFFErrorCode } from '../pkt/RelayPacketFFErrorCode';
1315
import { RelayVersion } from '../utils/RelayVersion';
14-
import { LocalWorld, RelayPacket07LocalWorlds } from '../pkt/RelayPacket07LocalWorlds';
1516
import { SocketAddress } from '../utils/SocketAddress';
17+
import { RateLimit, RateLimiter } from './RateLimiter';
1618
import '../pkt/RegisterPackets';
1719

1820
export class EaglerSPRelay {
@@ -24,13 +26,22 @@ export class EaglerSPRelay {
2426
private readonly SERVER_CONNECTIONS: Map<WebSocket, EaglerSPServer>;
2527
private readonly SERVER_ADDRESS_SETS: Map<string, EaglerSPServer[]>;
2628

29+
private readonly WORLD_RATE_LIMITER: RateLimiter | undefined;
30+
private readonly PING_RATE_LIMITER: RateLimiter | undefined;
31+
2732
public constructor ();
2833
public constructor (config: object);
2934
public constructor (config?: object) {
3035
this.WSS = new WebSocketServer({ noServer: true });
3136
if (config !== undefined) RelayConfig.loadConfigJSON(config);
3237
else RelayConfig.loadConfigFile('config.json');
3338
RelayLogger.debug('Debug logging enabled');
39+
if (RelayConfig.get('limits.world_ratelimit.enabled')) this.WORLD_RATE_LIMITER = new RateLimiter(Number(RelayConfig.get('limits.world_ratelimit.period')) * 1000, Number(RelayConfig.get('limits.world_ratelimit.limit')), Number(RelayConfig.get('limits.world_ratelimit.lockout_limit')), Number(RelayConfig.get('limits.world_ratelimit.lockout_time')) * 1000);
40+
if (RelayConfig.get('limits.ping_ratelimit.enabled')) this.PING_RATE_LIMITER = new RateLimiter(Number(RelayConfig.get('limits.ping_ratelimit.period')) * 1000, Number(RelayConfig.get('limits.ping_ratelimit.limit')), Number(RelayConfig.get('limits.ping_ratelimit.lockout_limit')), Number(RelayConfig.get('limits.ping_ratelimit.lockout_time')) * 1000);
41+
setInterval(() => {
42+
this.WORLD_RATE_LIMITER?.update();
43+
this.PING_RATE_LIMITER?.update();
44+
}, 30000);
3445
this.CLIENT_IDS = new Map();
3546
this.SERVER_CODES = new Map();
3647
this.PENDING_CONNECTIONS = new Map();
@@ -65,6 +76,14 @@ export class EaglerSPRelay {
6576
let id: string | undefined;
6677
let srv: EaglerSPServer | undefined;
6778
if (ipkt.CONNECTION_TYPE === 1) {
79+
if (!this.rateLimit(this.WORLD_RATE_LIMITER, ws, waiting.ADDRESS)) return;
80+
let arr: EaglerSPServer[] | undefined = this.SERVER_ADDRESS_SETS.get(waiting.ADDRESS);
81+
if (arr !== undefined && arr.length >= Number(RelayConfig.get('limits.worlds_per_ip'))) {
82+
RelayLogger.debug('[{}]: Too many worlds are open on this address', waiting.ADDRESS);
83+
ws.send(RelayPacketFEDisconnectClient.RATELIMIT_PACKET_TOO_MANY);
84+
ws.close();
85+
return;
86+
}
6887
RelayLogger.debug('[{}]: Connected as a server', waiting.ADDRESS);
6988
let i: number = 0;
7089
while (true) {
@@ -91,7 +110,7 @@ export class EaglerSPRelay {
91110
RelayLogger.debug('[{}] [Relay -> Server]: PKT 0x00: Assign join code: {}', waiting.ADDRESS, id);
92111
this.SERVER_CONNECTIONS.set(ws, srv);
93112
this.PENDING_CONNECTIONS.delete(ws);
94-
let arr: EaglerSPServer[] | undefined = this.SERVER_ADDRESS_SETS.get(srv.SERVER_ADDRESS);
113+
arr = this.SERVER_ADDRESS_SETS.get(srv.SERVER_ADDRESS);
95114
if (arr == undefined) {
96115
arr = [];
97116
this.SERVER_ADDRESS_SETS.set(srv.SERVER_ADDRESS, arr);
@@ -100,6 +119,7 @@ export class EaglerSPRelay {
100119
(srv).send(new RelayPacket01ICEServers(RelayConfig.getRelayServers()));
101120
RelayLogger.debug('[{}] [Relay -> Server]: PKT 0x01: Send ICE server list to server', waiting.ADDRESS);
102121
} else if (ipkt.CONNECTION_TYPE === 2) {
122+
if (!this.rateLimit(this.PING_RATE_LIMITER, ws, waiting.ADDRESS)) return;
103123
const codeLen: number = (RelayConfig.get('join_codes.length') as number);
104124
let code: string = ipkt.CONNECTION_CODE;
105125
RelayLogger.debug('[{}]: Connected as a client, requested server code: {}', waiting.ADDRESS, code);
@@ -129,10 +149,12 @@ export class EaglerSPRelay {
129149
RelayLogger.debug('[{}] [Relay -> Client]: PKT 0x01: Send ICE server list to client', waiting.ADDRESS);
130150
}
131151
} else if (ipkt.CONNECTION_TYPE === 3) {
152+
if (!this.rateLimit(this.PING_RATE_LIMITER, ws, waiting.ADDRESS)) return;
132153
RelayLogger.debug('[{}]: Pinging the server', waiting.ADDRESS);
133154
ws.send(RelayPacket.writePacket(new RelayPacket69Pong(1, RelayConfig.get('server.comment') as string, RelayVersion.BRAND)));
134155
ws.close();
135156
} else if (ipkt.CONNECTION_TYPE === 4) {
157+
if (!this.rateLimit(this.PING_RATE_LIMITER, ws, waiting.ADDRESS)) return;
136158
RelayLogger.debug('[{}]: Polling the server for other worlds', waiting.ADDRESS);
137159
if (RelayConfig.get('server.show_local_worlds')) ws.send(RelayPacket.writePacket(new RelayPacket07LocalWorlds(this.getLocalWorlds(SocketAddress.getAddress(ws)))));
138160
else ws.send(RelayPacket.writePacket(new RelayPacket07LocalWorlds([])));
@@ -220,6 +242,19 @@ export class EaglerSPRelay {
220242
if (srvs != undefined && srvs.length > 0) for (const s of srvs) if (!s.SERVER_HIDDEN) arr.push(new LocalWorld(s.SERVER_NAME, s.CODE));
221243
return arr;
222244
}
245+
246+
private rateLimit (l: RateLimiter | undefined, ws: WebSocket, addr: string): boolean {
247+
if (l === undefined) return true;
248+
const r = l.limit(addr);
249+
if (r === RateLimit.NONE) return true;
250+
if (r === RateLimit.LIMIT) {
251+
ws.send(RelayPacketFEDisconnectClient.RATELIMIT_PACKET_BLOCK);
252+
} else if (r === RateLimit.LIMIT_NOW_LOCKOUT) {
253+
ws.send(RelayPacketFEDisconnectClient.RATELIMIT_PACKET_BLOCK_LOCK);
254+
}
255+
ws.close();
256+
return false;
257+
}
223258
}
224259

225260
class PendingConnection {

src/server/EaglerSPServer.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import { RelayPacketFFErrorCode } from '../pkt/RelayPacketFFErrorCode';
1313
import { SocketAddress } from '../utils/SocketAddress';
1414

1515
export class EaglerSPServer {
16-
public SOCKET: WebSocket;
17-
public CODE: string;
18-
public CLIENTS: Map<string, EaglerSPClient>;
19-
public SERVER_NAME: string;
20-
public SERVER_ADDRESS: string;
21-
public SERVER_HIDDEN: boolean;
16+
public readonly SOCKET: WebSocket;
17+
public readonly CODE: string;
18+
public readonly CLIENTS: Map<string, EaglerSPClient>;
19+
public readonly SERVER_NAME: string;
20+
public readonly SERVER_ADDRESS: string;
21+
public readonly SERVER_HIDDEN: boolean;
2222

2323
public constructor (socket: WebSocket, code: string, serverName: string, serverAddress: string) {
2424
this.SOCKET = socket;

src/server/RateLimiter.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
export class RateLimiter {
2+
public readonly PERIOD: number;
3+
public readonly LIMIT: number;
4+
public readonly LOCKOUT_LIMIT: number;
5+
public readonly LOCKOUT_DURATION: number;
6+
7+
private readonly LIMITERS = new Map<string, RateLimitEntry>();
8+
9+
public constructor (period: number, limit: number, lockoutLimit: number, lockoutDuration: number) {
10+
this.PERIOD = period;
11+
this.LIMIT = limit;
12+
this.LOCKOUT_LIMIT = lockoutLimit;
13+
this.LOCKOUT_DURATION = lockoutDuration;
14+
}
15+
16+
public limit (addr: string): RateLimit {
17+
let etr: RateLimitEntry | undefined = this.LIMITERS.get(addr);
18+
if (etr === undefined) this.LIMITERS.set(addr, etr = new RateLimitEntry());
19+
else etr.update(this);
20+
if (etr.LOCKED) {
21+
return RateLimit.LOCKOUT;
22+
} else {
23+
if (++etr.COUNT >= this.LOCKOUT_LIMIT) {
24+
etr.COUNT = 0;
25+
etr.LOCKED = true;
26+
etr.LOCKED_TIMER = Date.now();
27+
return RateLimit.LIMIT_NOW_LOCKOUT;
28+
} else {
29+
return etr.COUNT > this.LIMIT ? RateLimit.LIMIT : RateLimit.NONE;
30+
}
31+
}
32+
}
33+
34+
public update (): void {
35+
for (const [address, etr] of this.LIMITERS) {
36+
etr.update(this);
37+
if (!etr.LOCKED && etr.COUNT === 0) this.LIMITERS.delete(address);
38+
}
39+
}
40+
41+
public reset (): void {
42+
this.LIMITERS.clear();
43+
}
44+
}
45+
46+
export enum RateLimit {
47+
NONE,
48+
LIMIT,
49+
LIMIT_NOW_LOCKOUT,
50+
LOCKOUT
51+
}
52+
53+
class RateLimitEntry {
54+
public TIMER: number = Date.now();
55+
public COUNT: number = 0;
56+
public LOCKED_TIMER: number = 0;
57+
public LOCKED: boolean = false;
58+
59+
public update (limiter: RateLimiter): void {
60+
const millis: number = Date.now();
61+
if (this.LOCKED) {
62+
if (millis - this.LOCKED_TIMER > limiter.LOCKOUT_DURATION) {
63+
this.LOCKED = false;
64+
this.LOCKED_TIMER = 0;
65+
this.COUNT = 0;
66+
this.TIMER = millis;
67+
}
68+
} else {
69+
const p: number = limiter.PERIOD / limiter.LIMIT;
70+
if (this.COUNT > 0 && p > 0) {
71+
const elapsed: number = millis - this.TIMER;
72+
const tokens: number = Math.floor(elapsed / p);
73+
if (tokens > 0) {
74+
this.COUNT = Math.max(0, this.COUNT - tokens);
75+
this.TIMER += tokens * p;
76+
if (this.TIMER > millis) this.TIMER = millis;
77+
}
78+
} else {
79+
this.TIMER = millis;
80+
}
81+
}
82+
}
83+
}

src/utils/RelayConfig.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,24 @@ const CONFIG_DEFAULT = {
4444
type: 'stun',
4545
address: 'stun4.l.google.com:19302'
4646
}
47-
]
47+
],
48+
limits: {
49+
worlds_per_ip: 32,
50+
world_ratelimit: {
51+
enabled: true,
52+
period: 192,
53+
limit: 32,
54+
lockout_limit: 48,
55+
lockout_time: 600
56+
},
57+
ping_ratelimit: {
58+
enabled: true,
59+
period: 256,
60+
limit: 128,
61+
lockout_limit: 192,
62+
lockout_time: 300
63+
}
64+
}
4865
};
4966

5067
type Json = string | number | boolean | null | Json[] | { [k: string]: Json; };

src/utils/RelayLogger.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import chalk from 'chalk';
22
import { RelayConfig } from './RelayConfig';
33

44
export class RelayLogger {
5-
private constructor () {}
6-
75
private static format (msg: any, ...args: any[]): string {
86
let ret = msg;
97
for (const arg of args) ret = ret.replace('{}', typeof arg === 'object' ? JSON.stringify(arg) : String(arg));

src/utils/RelayVersion.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export class RelayVersion {
22
public static readonly NAME = 'EaglerRelayJS';
3-
public static readonly VERSION = '0.1.0';
3+
public static readonly VERSION = '0.1.1';
44
public static readonly BRAND = 'colbster';
55
public static readonly PROTOCOL = 1;
66
public static readonly DEFAULT_COMMENT = `${RelayVersion.NAME} v${RelayVersion.VERSION}`;
7-
}
7+
}

0 commit comments

Comments
 (0)