@serverkgg/bridge 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BridgeProbeCode, BridgeProbeError } from "../core";
3
+ import { parseUnconnectedPong, probeRaknetPing } from "./raknetPing";
4
+
5
+ const HOST = "127.0.0.1";
6
+
7
+ const CLOSED_PORT = 59_443;
8
+
9
+ const MAGIC = [
10
+ 0x00,
11
+ 0xff,
12
+ 0xff,
13
+ 0x00,
14
+ 0xfe,
15
+ 0xfe,
16
+ 0xfe,
17
+ 0xfe,
18
+ 0xfd,
19
+ 0xfd,
20
+ 0xfd,
21
+ 0xfd,
22
+ 0x12,
23
+ 0x34,
24
+ 0x56,
25
+ 0x78,
26
+ ];
27
+
28
+ const ADVERT = "MCPE;Serverk Bedrock;800;1.21.60;4;10;13253860808220445253;بيت الشباب;Survival;1;19132;19133";
29
+
30
+ const pong = (
31
+ advert: string,
32
+ options: {
33
+ header?: number;
34
+ magic?: number[];
35
+ length?: number;
36
+ } = {},
37
+ ) => {
38
+ const payload = new TextEncoder().encode(advert);
39
+ const length = options.length ?? payload.length;
40
+
41
+ return new Uint8Array([
42
+ options.header ?? 0x1c,
43
+ ...new Array(8).fill(0),
44
+ ...new Array(8).fill(1),
45
+ ...(options.magic ?? MAGIC),
46
+ (length >> 8) & 0xff,
47
+ length & 0xff,
48
+ ...payload,
49
+ ]);
50
+ };
51
+
52
+ const listen = async (reply: Uint8Array | null) => {
53
+ return await Bun.udpSocket({
54
+ hostname: HOST,
55
+ port: 0,
56
+ socket: {
57
+ data(socket, _data, port, address) {
58
+ if (reply) {
59
+ socket.send(reply, port, address);
60
+ }
61
+ },
62
+ },
63
+ });
64
+ };
65
+
66
+ describe("reading an unconnected pong", () => {
67
+ test("a full advertisement gives the players, the version and the motd", () => {
68
+ expect(parseUnconnectedPong(pong(ADVERT))).toEqual({
69
+ players: {
70
+ online: 4,
71
+ max: 10,
72
+ },
73
+ edition: "MCPE",
74
+ motd: "Serverk Bedrock",
75
+ subMotd: "بيت الشباب",
76
+ version: "1.21.60",
77
+ protocol: 800,
78
+ gamemode: "Survival",
79
+ });
80
+ });
81
+
82
+ test("an advertisement that stops after the player counts is still read", () => {
83
+ expect(parseUnconnectedPong(pong("MCPE;Serverk;800;1.21.60;2;8"))).toEqual({
84
+ players: {
85
+ online: 2,
86
+ max: 8,
87
+ },
88
+ edition: "MCPE",
89
+ motd: "Serverk",
90
+ subMotd: null,
91
+ version: "1.21.60",
92
+ protocol: 800,
93
+ gamemode: null,
94
+ });
95
+ });
96
+
97
+ test("a pong with another packet id is refused", () => {
98
+ expect(() =>
99
+ parseUnconnectedPong(
100
+ pong(ADVERT, {
101
+ header: 0x1d,
102
+ }),
103
+ ),
104
+ ).toThrow();
105
+ });
106
+
107
+ test("a pong without the raknet magic is refused", () => {
108
+ expect(() =>
109
+ parseUnconnectedPong(
110
+ pong(ADVERT, {
111
+ magic: new Array(16).fill(0),
112
+ }),
113
+ ),
114
+ ).toThrow();
115
+ });
116
+
117
+ test("a pong that advertises more payload than it carries is refused", () => {
118
+ expect(() =>
119
+ parseUnconnectedPong(
120
+ pong(ADVERT, {
121
+ length: 4096,
122
+ }),
123
+ ),
124
+ ).toThrow();
125
+ });
126
+
127
+ test("a pong without player counts is refused, because a sample without them is a lie", () => {
128
+ expect(() => parseUnconnectedPong(pong("MCPE;Serverk;800;1.21.60"))).toThrow();
129
+ });
130
+
131
+ test("a datagram larger than a pong can be is refused", () => {
132
+ expect(() => parseUnconnectedPong(pong("n".repeat(4096)))).toThrow();
133
+ });
134
+
135
+ test("a truncated datagram is refused", () => {
136
+ expect(() => parseUnconnectedPong(new Uint8Array(8))).toThrow();
137
+ });
138
+ });
139
+
140
+ describe("probeRaknetPing", () => {
141
+ test("answers with the advertisement a listening server sends back", async () => {
142
+ const server = await listen(pong(ADVERT));
143
+
144
+ try {
145
+ const sample = await probeRaknetPing(HOST, server.port, 2000);
146
+
147
+ expect(sample.players).toEqual({
148
+ online: 4,
149
+ max: 10,
150
+ });
151
+ expect(sample.motd).toBe("Serverk Bedrock");
152
+ expect(sample.latencyMs).toBeGreaterThanOrEqual(0);
153
+ } finally {
154
+ server.close();
155
+ }
156
+ });
157
+
158
+ test("rejects with an unreachable probe error when nothing listens on the port", async () => {
159
+ const probe = probeRaknetPing(HOST, CLOSED_PORT, 2000);
160
+
161
+ await expect(probe).rejects.toBeInstanceOf(BridgeProbeError);
162
+ });
163
+
164
+ test("times out against a port that never answers", async () => {
165
+ const silent = await listen(null);
166
+
167
+ try {
168
+ const error = await probeRaknetPing(HOST, silent.port, 250).catch((reason: unknown) => reason);
169
+
170
+ expect(error).toBeInstanceOf(BridgeProbeError);
171
+ expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.TimedOut);
172
+ } finally {
173
+ silent.close();
174
+ }
175
+ });
176
+
177
+ test("rejects a reply that is not a raknet pong", async () => {
178
+ const noisy = await listen(
179
+ new Uint8Array([
180
+ 1,
181
+ 2,
182
+ 3,
183
+ 4,
184
+ 5,
185
+ ]),
186
+ );
187
+
188
+ try {
189
+ const error = await probeRaknetPing(HOST, noisy.port, 2000).catch((reason: unknown) => reason);
190
+
191
+ expect(error).toBeInstanceOf(BridgeProbeError);
192
+ expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.Malformed);
193
+ } finally {
194
+ noisy.close();
195
+ }
196
+ });
197
+ });
@@ -0,0 +1,228 @@
1
+ import type { BridgeRaknetPingStatus } from "../context";
2
+ import { BridgeProbeCode, BridgeProbeError } from "../core";
3
+
4
+ const UNCONNECTED_PING = 0x01;
5
+
6
+ const UNCONNECTED_PONG = 0x1c;
7
+
8
+ const MAGIC = new Uint8Array([
9
+ 0x00,
10
+ 0xff,
11
+ 0xff,
12
+ 0x00,
13
+ 0xfe,
14
+ 0xfe,
15
+ 0xfe,
16
+ 0xfe,
17
+ 0xfd,
18
+ 0xfd,
19
+ 0xfd,
20
+ 0xfd,
21
+ 0x12,
22
+ 0x34,
23
+ 0x56,
24
+ 0x78,
25
+ ]);
26
+
27
+ const REQUEST_BYTES = 33;
28
+
29
+ const MAGIC_OFFSET = 17;
30
+
31
+ const LENGTH_OFFSET = MAGIC_OFFSET + MAGIC.length;
32
+
33
+ const STRING_OFFSET = LENGTH_OFFSET + 2;
34
+
35
+ const MAX_RESPONSE_BYTES = 2 * 1024;
36
+
37
+ const FIELD_EDITION = 0;
38
+
39
+ const FIELD_MOTD = 1;
40
+
41
+ const FIELD_PROTOCOL = 2;
42
+
43
+ const FIELD_VERSION = 3;
44
+
45
+ const FIELD_ONLINE = 4;
46
+
47
+ const FIELD_MAX = 5;
48
+
49
+ const FIELD_SUB_MOTD = 7;
50
+
51
+ const FIELD_GAMEMODE = 8;
52
+
53
+ const DECODER = new TextDecoder();
54
+
55
+ const pingRequest = () => {
56
+ const request = new Uint8Array(REQUEST_BYTES);
57
+ const view = new DataView(request.buffer);
58
+
59
+ const guid = new BigUint64Array(1);
60
+
61
+ crypto.getRandomValues(guid);
62
+
63
+ request[0] = UNCONNECTED_PING;
64
+
65
+ view.setBigInt64(1, BigInt(Math.trunc(performance.timeOrigin + performance.now())), false);
66
+ request.set(MAGIC, 9);
67
+ view.setBigUint64(25, guid[0] ?? 0n, false);
68
+
69
+ return request;
70
+ };
71
+
72
+ const text = (fields: string[], index: number): string | null => {
73
+ const value = fields[index]?.trim();
74
+
75
+ return value !== undefined && value.length > 0 ? value : null;
76
+ };
77
+
78
+ const count = (fields: string[], index: number): number | null => {
79
+ const value = text(fields, index);
80
+
81
+ if (value === null) {
82
+ return null;
83
+ }
84
+
85
+ const parsed = Number.parseInt(value, 10);
86
+
87
+ return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : null;
88
+ };
89
+
90
+ export const parseUnconnectedPong = (view: Uint8Array): Omit<BridgeRaknetPingStatus, "latencyMs"> => {
91
+ if (view.length > MAX_RESPONSE_BYTES) {
92
+ throw new Error("raknet pong is larger than a pong can be");
93
+ }
94
+
95
+ if (view.length < STRING_OFFSET || view[0] !== UNCONNECTED_PONG) {
96
+ throw new Error("raknet pong has an unexpected header");
97
+ }
98
+
99
+ if (!MAGIC.every((byte, index) => view[MAGIC_OFFSET + index] === byte)) {
100
+ throw new Error("raknet pong does not carry the raknet magic");
101
+ }
102
+
103
+ const high = view[LENGTH_OFFSET];
104
+ const low = view[LENGTH_OFFSET + 1];
105
+
106
+ if (high === undefined || low === undefined) {
107
+ throw new Error("raknet pong is truncated");
108
+ }
109
+
110
+ const length = (high << 8) | low;
111
+
112
+ if (view.length < STRING_OFFSET + length) {
113
+ throw new Error("raknet pong advertises more payload than it carries");
114
+ }
115
+
116
+ const fields = DECODER.decode(view.subarray(STRING_OFFSET, STRING_OFFSET + length)).split(";");
117
+
118
+ const online = count(fields, FIELD_ONLINE);
119
+ const max = count(fields, FIELD_MAX);
120
+
121
+ if (online === null || max === null) {
122
+ throw new Error("raknet pong is missing player counts");
123
+ }
124
+
125
+ return {
126
+ players: {
127
+ online,
128
+ max,
129
+ },
130
+ edition: text(fields, FIELD_EDITION),
131
+ motd: text(fields, FIELD_MOTD),
132
+ subMotd: text(fields, FIELD_SUB_MOTD),
133
+ version: text(fields, FIELD_VERSION),
134
+ protocol: count(fields, FIELD_PROTOCOL),
135
+ gamemode: text(fields, FIELD_GAMEMODE),
136
+ };
137
+ };
138
+
139
+ const probeError = (code: BridgeProbeCode, message: string, cause: unknown) => {
140
+ if (cause instanceof BridgeProbeError) {
141
+ return cause;
142
+ }
143
+
144
+ const detail = cause instanceof Error && cause.message.length > 0 ? `: ${cause.message}` : "";
145
+
146
+ return new BridgeProbeError(code, `${message}${detail}`);
147
+ };
148
+
149
+ export const probeRaknetPing = (host: string, hostPort: number, timeoutMs: number) => {
150
+ const target = `${host}:${hostPort}`;
151
+
152
+ return new Promise<BridgeRaknetPingStatus>((resolve, reject) => {
153
+ const startedAt = performance.now();
154
+
155
+ let socket: Awaited<ReturnType<typeof Bun.udpSocket>> | null = null;
156
+ let settled = false;
157
+
158
+ const finish = (outcome: Omit<BridgeRaknetPingStatus, "latencyMs"> | BridgeProbeError) => {
159
+ if (settled) {
160
+ return;
161
+ }
162
+
163
+ settled = true;
164
+
165
+ clearTimeout(timer);
166
+
167
+ try {
168
+ socket?.close();
169
+ } catch {}
170
+
171
+ if (outcome instanceof BridgeProbeError) {
172
+ reject(outcome);
173
+
174
+ return;
175
+ }
176
+
177
+ resolve({
178
+ ...outcome,
179
+ latencyMs: Math.round(performance.now() - startedAt),
180
+ });
181
+ };
182
+
183
+ const timer = setTimeout(() => {
184
+ finish(new BridgeProbeError(BridgeProbeCode.TimedOut, `raknet ping to ${target} timed out`));
185
+ }, timeoutMs);
186
+
187
+ const handle = (view: Uint8Array) => {
188
+ try {
189
+ finish(parseUnconnectedPong(view));
190
+ } catch (error) {
191
+ finish(probeError(BridgeProbeCode.Malformed, `raknet pong from ${target} could not be read`, error));
192
+ }
193
+ };
194
+
195
+ Bun.udpSocket({
196
+ connect: {
197
+ hostname: host,
198
+ port: hostPort,
199
+ },
200
+ socket: {
201
+ data(_socket, data) {
202
+ handle(data);
203
+ },
204
+ error(_socket, error) {
205
+ finish(probeError(BridgeProbeCode.Unreachable, `raknet ping to ${target} failed`, error));
206
+ },
207
+ },
208
+ })
209
+ .then((created) => {
210
+ if (settled) {
211
+ created.close();
212
+
213
+ return;
214
+ }
215
+
216
+ socket = created;
217
+
218
+ try {
219
+ created.send(pingRequest());
220
+ } catch (error) {
221
+ finish(probeError(BridgeProbeCode.Unreachable, `raknet ping to ${target} could not be sent`, error));
222
+ }
223
+ })
224
+ .catch((error) => {
225
+ finish(probeError(BridgeProbeCode.Unreachable, `raknet socket for ${target} could not be opened`, error));
226
+ });
227
+ });
228
+ };
@@ -40,6 +40,8 @@ export const BRIDGE_PENDING_FILES_LIMIT = 64;
40
40
 
41
41
  export const BRIDGE_SETUP_STEPS_LIMIT = 8;
42
42
 
43
+ export const BRIDGE_SETUP_ACTIONS_LIMIT = 4;
44
+
43
45
  export const BRIDGE_ID_LENGTH_LIMIT = 32;
44
46
 
45
47
  export const BRIDGE_KEY_LENGTH_LIMIT = 64;
@@ -53,3 +55,9 @@ export const BRIDGE_PLACEHOLDER_LENGTH_LIMIT = 256;
53
55
  export const BRIDGE_STAGING_LENGTH_LIMIT = 128;
54
56
 
55
57
  export const BRIDGE_EXTENSION_LENGTH_LIMIT = 16;
58
+
59
+ export const BRIDGE_PATTERN_LENGTH_LIMIT = 256;
60
+
61
+ export const BRIDGE_SETUP_CODE_LENGTH_LIMIT = 64;
62
+
63
+ export const BRIDGE_SETUP_DETAIL_LENGTH_LIMIT = 256;
@@ -1155,6 +1155,11 @@ const driverWithSetup = (setup: BridgeSetupModule): BridgeDriver => ({
1155
1155
  fields: [
1156
1156
  field("SERVER_NAME"),
1157
1157
  field("MOTD"),
1158
+ {
1159
+ key: "ADMIN_PASSWORD",
1160
+ control: BridgeControl.Secret,
1161
+ label: text("Admin password"),
1162
+ },
1158
1163
  ],
1159
1164
  },
1160
1165
  ],
@@ -1172,6 +1177,40 @@ const driverStep = (id: string) => ({
1172
1177
  const setupWith = (steps: BridgeSetupStep[]): BridgeSetupModule => ({
1173
1178
  kind: BridgeKind.Setup,
1174
1179
  steps,
1180
+ submit: async () => {
1181
+ return;
1182
+ },
1183
+ });
1184
+
1185
+ const setupWithoutSubmit = (steps: BridgeSetupStep[]): BridgeSetupModule => ({
1186
+ kind: BridgeKind.Setup,
1187
+ steps,
1188
+ });
1189
+
1190
+ const driverWithFields = (fields: BridgeField[]): BridgeDriver => ({
1191
+ install: {
1192
+ kind: BridgeKind.Install,
1193
+ run: async () => {
1194
+ return;
1195
+ },
1196
+ },
1197
+ lifecycle: {
1198
+ kind: BridgeKind.Lifecycle,
1199
+ command: async () => [
1200
+ "run",
1201
+ ],
1202
+ },
1203
+ panel: {
1204
+ tabs: [
1205
+ {
1206
+ id: "settings",
1207
+ title: text("Settings"),
1208
+ sections: [
1209
+ formSection("server", fields),
1210
+ ],
1211
+ },
1212
+ ],
1213
+ },
1175
1214
  });
1176
1215
 
1177
1216
  describe("the setup singleton", () => {
@@ -1370,6 +1409,58 @@ describe("the setup singleton", () => {
1370
1409
  ).not.toThrow();
1371
1410
  });
1372
1411
 
1412
+ test("takes a form step over a secret field", () => {
1413
+ expect(() =>
1414
+ validateDriver(
1415
+ driverWithSetup(
1416
+ setupWith([
1417
+ {
1418
+ kind: BridgeSetupStepKind.Form,
1419
+ id: "password",
1420
+ title: text("Password"),
1421
+ tab: "settings",
1422
+ section: "server",
1423
+ fields: [
1424
+ "ADMIN_PASSWORD",
1425
+ ],
1426
+ },
1427
+ ]),
1428
+ ),
1429
+ ),
1430
+ ).not.toThrow();
1431
+ });
1432
+
1433
+ test("rejects a driver step while the setup exports no submit", () => {
1434
+ expect(() =>
1435
+ validateDriver(
1436
+ driverWithSetup(
1437
+ setupWithoutSubmit([
1438
+ driverStep("sign-in"),
1439
+ ]),
1440
+ ),
1441
+ ),
1442
+ ).toThrow("the setup module declares a driver step but exports no submit");
1443
+ });
1444
+
1445
+ test("takes a setup with no submit while every step is answered elsewhere", () => {
1446
+ expect(() =>
1447
+ validateDriver(
1448
+ driverWithSetup(
1449
+ setupWithoutSubmit([
1450
+ {
1451
+ kind: BridgeSetupStepKind.Open,
1452
+ id: "invite",
1453
+ title: text("Invite"),
1454
+ target: {
1455
+ tab: GuideOpenTab.Access,
1456
+ },
1457
+ },
1458
+ ]),
1459
+ ),
1460
+ ),
1461
+ ).not.toThrow();
1462
+ });
1463
+
1373
1464
  test("reserves the setup module id for the singleton", () => {
1374
1465
  expect(() =>
1375
1466
  validateDriver({
@@ -1395,3 +1486,152 @@ describe("the setup singleton", () => {
1395
1486
  ).toThrow('module id "setup" is reserved');
1396
1487
  });
1397
1488
  });
1489
+
1490
+ describe("field patterns", () => {
1491
+ test("takes a pattern on a text field and on a secret one", () => {
1492
+ expect(() =>
1493
+ validateDriver(
1494
+ driverWithFields([
1495
+ {
1496
+ key: "LICENSE_KEY",
1497
+ control: BridgeControl.Secret,
1498
+ label: text("Licence key"),
1499
+ pattern: "^cfxk_\\w{1,60}_\\w{1,20}$",
1500
+ patternHint: text("A licence key starts with cfxk_"),
1501
+ },
1502
+ {
1503
+ key: "SERVER_NAME",
1504
+ control: BridgeControl.Text,
1505
+ label: text("Name"),
1506
+ pattern: "^.{1,32}$",
1507
+ },
1508
+ ]),
1509
+ ),
1510
+ ).not.toThrow();
1511
+ });
1512
+
1513
+ test("rejects a pattern that is not a regular expression", () => {
1514
+ expect(() =>
1515
+ validateDriver(
1516
+ driverWithFields([
1517
+ {
1518
+ key: "LICENSE_KEY",
1519
+ control: BridgeControl.Text,
1520
+ label: text("Licence key"),
1521
+ pattern: "^cfxk_(",
1522
+ },
1523
+ ]),
1524
+ ),
1525
+ ).toThrow('declares pattern "^cfxk_(", which is not a valid regular expression');
1526
+ });
1527
+
1528
+ test("takes the FiveM licence pattern, whose repeats are all bounded", () => {
1529
+ expect(() =>
1530
+ validateDriver(
1531
+ driverWithFields([
1532
+ {
1533
+ key: "LICENSE_KEY",
1534
+ control: BridgeControl.Secret,
1535
+ label: text("Licence key"),
1536
+ pattern: "^(?:cfxk_\\w{1,60}_\\w{1,20}|\\w{32})$",
1537
+ },
1538
+ ]),
1539
+ ),
1540
+ ).not.toThrow();
1541
+ });
1542
+
1543
+ test("rejects a pattern that repeats a group which already repeats", () => {
1544
+ expect(() =>
1545
+ validateDriver(
1546
+ driverWithFields([
1547
+ {
1548
+ key: "LICENSE_KEY",
1549
+ control: BridgeControl.Text,
1550
+ label: text("Licence key"),
1551
+ pattern: "^(a+)+$",
1552
+ },
1553
+ ]),
1554
+ ),
1555
+ ).toThrow('declares pattern "^(a+)+$", which repeats a group that already repeats');
1556
+ });
1557
+
1558
+ test("rejects a starred group whose body repeats without a bound", () => {
1559
+ expect(() =>
1560
+ validateDriver(
1561
+ driverWithFields([
1562
+ {
1563
+ key: "SERVER_NAME",
1564
+ control: BridgeControl.Text,
1565
+ label: text("Name"),
1566
+ pattern: "^(\\w+\\s?)*$",
1567
+ },
1568
+ ]),
1569
+ ),
1570
+ ).toThrow("repeats a group that already repeats");
1571
+ });
1572
+
1573
+ test("rejects a pattern longer than the platform takes", () => {
1574
+ expect(() =>
1575
+ validateDriver(
1576
+ driverWithFields([
1577
+ {
1578
+ key: "LICENSE_KEY",
1579
+ control: BridgeControl.Text,
1580
+ label: text("Licence key"),
1581
+ pattern: `^${"a".repeat(256)}$`,
1582
+ },
1583
+ ]),
1584
+ ),
1585
+ ).toThrow("pattern is 258 characters — the platform takes at most 256");
1586
+ });
1587
+
1588
+ test("rejects a pattern hint with no pattern to explain", () => {
1589
+ expect(() =>
1590
+ validateDriver(
1591
+ driverWithFields([
1592
+ {
1593
+ key: "LICENSE_KEY",
1594
+ control: BridgeControl.Text,
1595
+ label: text("Licence key"),
1596
+ patternHint: text("A licence key starts with cfxk_"),
1597
+ },
1598
+ ]),
1599
+ ),
1600
+ ).toThrow("declares a pattern hint with no pattern to explain");
1601
+ });
1602
+
1603
+ test("rejects a pattern on a control that never matches one", () => {
1604
+ expect(() =>
1605
+ validateDriver(
1606
+ driverWithFields([
1607
+ {
1608
+ key: "WHITELIST",
1609
+ control: BridgeControl.Boolean,
1610
+ label: text("Whitelist"),
1611
+ pattern: "^true$",
1612
+ },
1613
+ ]),
1614
+ ),
1615
+ ).toThrow('declares a pattern on a "boolean" control — only text and secret controls match one');
1616
+ });
1617
+
1618
+ test("rejects a secret control that offers options", () => {
1619
+ expect(() =>
1620
+ validateDriver(
1621
+ driverWithFields([
1622
+ {
1623
+ key: "ADMIN_PASSWORD",
1624
+ control: BridgeControl.Secret,
1625
+ label: text("Admin password"),
1626
+ options: [
1627
+ {
1628
+ value: "letmein",
1629
+ label: text("letmein"),
1630
+ },
1631
+ ],
1632
+ },
1633
+ ]),
1634
+ ),
1635
+ ).toThrow("is a secret control with options — a secret is typed, never chosen from a list");
1636
+ });
1637
+ });