@serverkgg/bridge 0.0.13 → 0.0.15

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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/cli/validate.test.ts +194 -2
  3. package/src/cli/validate.ts +83 -1
  4. package/src/context/bridgeContext.ts +9 -2
  5. package/src/guides/variables.ts +1 -0
  6. package/src/manifest/manifest.ts +1 -1
  7. package/src/modules/bridgeModule.ts +2 -1
  8. package/src/panel/bridgePanel.ts +11 -0
  9. package/src/panel/bridgePlace.ts +4 -0
  10. package/src/panel/index.ts +1 -0
  11. package/src/rcon/index.ts +3 -2
  12. package/src/rcon/rconAccess.test.ts +304 -0
  13. package/src/rcon/rconAccess.ts +260 -0
  14. package/src/rcon/rconClient.test.ts +1544 -0
  15. package/src/rcon/rconClient.ts +275 -0
  16. package/src/rcon/rconError.ts +21 -0
  17. package/src/rcon/rconFrame.test.ts +269 -0
  18. package/src/rcon/{sourceRconFrame.ts → rconFrame.ts} +23 -14
  19. package/src/rcon/rconIds.test.ts +59 -0
  20. package/src/rcon/rconIds.ts +33 -0
  21. package/src/rcon/rconLink.ts +427 -0
  22. package/src/rcon/rconWriter.test.ts +95 -0
  23. package/src/rcon/rconWriter.ts +63 -0
  24. package/src/runtime/bridgeConsoleHandler.test.ts +196 -0
  25. package/src/runtime/bridgeConsoleHandler.ts +26 -0
  26. package/src/runtime/bridgeDriver.test.ts +145 -0
  27. package/src/runtime/bridgeDriver.ts +44 -2
  28. package/src/runtime/bridgeRcon.ts +6 -2
  29. package/src/runtime/bridgeRegistry.test.ts +96 -0
  30. package/src/runtime/bridgeRegistry.ts +33 -1
  31. package/src/runtime/serve.ts +10 -1
  32. package/src/steam/avatars.test.ts +306 -0
  33. package/src/steam/avatars.ts +107 -0
  34. package/src/steam/index.ts +1 -0
  35. package/src/wire/panel.test.ts +79 -0
  36. package/src/wire/panel.ts +11 -0
  37. package/src/rcon/sourceRcon.test.ts +0 -308
  38. package/src/rcon/sourceRcon.ts +0 -276
  39. package/src/rcon/sourceRconFrame.test.ts +0 -152
@@ -0,0 +1,304 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeContext } from "../context";
3
+ import { BridgeDetailFormat, BridgeDetailTone, BridgeFormTarget, BridgeLayout } from "../panel";
4
+ import {
5
+ createRconAccess,
6
+ RCON_ACCESS_MODULE,
7
+ RCON_ACCESS_PORT,
8
+ RCON_ACCESS_ROTATE,
9
+ RCON_ACCESS_VARIABLE,
10
+ type RconAccessPassword,
11
+ rconAccessSections,
12
+ rconBindHost,
13
+ rconExposed,
14
+ } from "./rconAccess";
15
+
16
+ interface StubOptions {
17
+ exposed?: boolean;
18
+ port?: number | null;
19
+ }
20
+
21
+ const stubContext = (options: StubOptions = {}) => {
22
+ const logged: string[] = [];
23
+
24
+ const context = {
25
+ variable: (key: string) => {
26
+ return key === RCON_ACCESS_VARIABLE && options.exposed === true ? "true" : null;
27
+ },
28
+ port: (key: string) => {
29
+ if (options.port === null) {
30
+ throw new Error(`this game declares no port named "${key}"`);
31
+ }
32
+
33
+ return options.port ?? 12_500;
34
+ },
35
+ log: (message: string) => {
36
+ logged.push(message);
37
+ },
38
+ } as unknown as BridgeContext;
39
+
40
+ return {
41
+ context,
42
+ logged,
43
+ };
44
+ };
45
+
46
+ const accessWith = (password: RconAccessPassword | null, rotate?: () => Promise<void>) => {
47
+ return createRconAccess({
48
+ async password() {
49
+ return password;
50
+ },
51
+ async rotate() {
52
+ await rotate?.();
53
+ },
54
+ });
55
+ };
56
+
57
+ describe("rconExposed", () => {
58
+ test("only the string true opens remote access", () => {
59
+ expect(
60
+ rconExposed(
61
+ stubContext({
62
+ exposed: true,
63
+ }).context,
64
+ ),
65
+ ).toBe(true);
66
+ expect(rconExposed(stubContext().context)).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe("rconBindHost", () => {
71
+ test("an exposed server binds every interface and a closed one stays on loopback", () => {
72
+ expect(
73
+ rconBindHost(
74
+ stubContext({
75
+ exposed: true,
76
+ }).context,
77
+ ),
78
+ ).toBe("0.0.0.0");
79
+ expect(rconBindHost(stubContext().context)).toBe("127.0.0.1");
80
+ });
81
+ });
82
+
83
+ describe("createRconAccess", () => {
84
+ test("an exposed server reads as open", async () => {
85
+ const detail = await accessWith({
86
+ value: "secret",
87
+ pending: false,
88
+ }).read(
89
+ stubContext({
90
+ exposed: true,
91
+ port: 12_501,
92
+ }).context,
93
+ );
94
+
95
+ expect(detail?.id).toBe(RCON_ACCESS_MODULE);
96
+ expect(detail?.badges.map((badge) => badge.label.en)).toEqual([
97
+ "Open",
98
+ ]);
99
+ expect(detail?.badges[0]?.tone).toBe(BridgeDetailTone.Success);
100
+ expect(detail?.actions).toEqual([
101
+ RCON_ACCESS_ROTATE,
102
+ ]);
103
+ });
104
+
105
+ test("a closed server reads as closed", async () => {
106
+ const detail = await accessWith({
107
+ value: "secret",
108
+ pending: false,
109
+ }).read(stubContext().context);
110
+
111
+ expect(detail?.badges.map((badge) => badge.label.en)).toEqual([
112
+ "Closed",
113
+ ]);
114
+ expect(detail?.badges[0]?.tone).toBe(BridgeDetailTone.Neutral);
115
+ });
116
+
117
+ test("the port and the password are the two stats", async () => {
118
+ const detail = await accessWith({
119
+ value: "secret",
120
+ pending: false,
121
+ }).read(
122
+ stubContext({
123
+ port: 12_507,
124
+ }).context,
125
+ );
126
+
127
+ expect(detail?.stats).toEqual([
128
+ {
129
+ key: "port",
130
+ label: {
131
+ ar: "منفذ RCON",
132
+ en: "RCON port",
133
+ },
134
+ value: 12_507,
135
+ format: BridgeDetailFormat.Number,
136
+ },
137
+ {
138
+ key: "password",
139
+ label: {
140
+ ar: "كلمة مرور RCON",
141
+ en: "RCON password",
142
+ },
143
+ value: "secret",
144
+ format: BridgeDetailFormat.Secret,
145
+ },
146
+ ]);
147
+ });
148
+
149
+ test("a password waiting for the next restart carries the pending badge", async () => {
150
+ const detail = await accessWith({
151
+ value: "next",
152
+ pending: true,
153
+ }).read(stubContext().context);
154
+
155
+ expect(detail?.badges.map((badge) => badge.label.en)).toEqual([
156
+ "Closed",
157
+ "Applies after restart",
158
+ ]);
159
+ });
160
+
161
+ test("an inactive port leaves the port stat empty instead of failing the read", async () => {
162
+ const detail = await accessWith({
163
+ value: "secret",
164
+ pending: false,
165
+ }).read(
166
+ stubContext({
167
+ port: null,
168
+ }).context,
169
+ );
170
+
171
+ expect(detail?.stats.find((stat) => stat.key === "port")?.value).toBeNull();
172
+ expect(detail?.stats.find((stat) => stat.key === "password")?.value).toBe("secret");
173
+ });
174
+
175
+ test("a server with no password yet has no detail", async () => {
176
+ expect(await accessWith(null).read(stubContext().context)).toBeNull();
177
+ });
178
+
179
+ test("rotating delegates to the driver, logs without the value and answers nothing", async () => {
180
+ let rotated = 0;
181
+
182
+ const access = accessWith(
183
+ {
184
+ value: "secret",
185
+ pending: false,
186
+ },
187
+ async () => {
188
+ rotated += 1;
189
+ },
190
+ );
191
+
192
+ const { context, logged } = stubContext();
193
+ const outcome = await access.actions?.[RCON_ACCESS_ROTATE]?.(context, {});
194
+
195
+ expect(rotated).toBe(1);
196
+ expect(outcome).toBeNull();
197
+ expect(logged).toEqual([
198
+ "rotated the remote access password",
199
+ ]);
200
+ });
201
+
202
+ test("the protocol name names the detail and both stats", async () => {
203
+ const detail = await createRconAccess({
204
+ protocol: {
205
+ ar: "Telnet",
206
+ en: "Telnet",
207
+ },
208
+ async password() {
209
+ return {
210
+ value: "secret",
211
+ pending: false,
212
+ };
213
+ },
214
+ async rotate() {
215
+ return;
216
+ },
217
+ }).read(stubContext().context);
218
+
219
+ expect(detail?.subtitle).toEqual({
220
+ ar: "Telnet",
221
+ en: "Telnet",
222
+ });
223
+ expect(detail?.stats.map((stat) => stat.label)).toEqual([
224
+ {
225
+ ar: "منفذ Telnet",
226
+ en: "Telnet port",
227
+ },
228
+ {
229
+ ar: "كلمة مرور Telnet",
230
+ en: "Telnet password",
231
+ },
232
+ ]);
233
+ });
234
+
235
+ test("a tools line joins the security copy", async () => {
236
+ const detail = await createRconAccess({
237
+ tools: {
238
+ ar: "أدوات مثل RustAdmin.",
239
+ en: "Tools such as RustAdmin.",
240
+ },
241
+ async password() {
242
+ return {
243
+ value: "secret",
244
+ pending: false,
245
+ };
246
+ },
247
+ async rotate() {
248
+ return;
249
+ },
250
+ }).read(stubContext().context);
251
+
252
+ expect(detail?.description).toMatchObject({
253
+ en: expect.stringContaining("Tools such as RustAdmin."),
254
+ });
255
+ });
256
+ });
257
+
258
+ describe("rconAccessSections", () => {
259
+ test("the form declares the variable the manifest activates the port on", () => {
260
+ const [form] = rconAccessSections();
261
+
262
+ expect(form?.layout).toBe(BridgeLayout.Form);
263
+
264
+ if (form?.layout !== BridgeLayout.Form) {
265
+ throw new Error("the first section is the toggle form");
266
+ }
267
+
268
+ expect(form.target).toBe(BridgeFormTarget.Variables);
269
+ expect(form.reinstall).toBe(false);
270
+ expect(form.fields.map((field) => field.key)).toEqual([
271
+ RCON_ACCESS_VARIABLE,
272
+ ]);
273
+ });
274
+
275
+ test("the detail points at the module and offers the rotate action", () => {
276
+ const [, detail] = rconAccessSections();
277
+
278
+ if (detail?.layout !== BridgeLayout.Detail) {
279
+ throw new Error("the second section is the detail");
280
+ }
281
+
282
+ expect(detail.module).toBe(RCON_ACCESS_MODULE);
283
+ expect(detail.actions?.map((action) => action.id)).toEqual([
284
+ RCON_ACCESS_ROTATE,
285
+ ]);
286
+ expect(detail.empty).toBeDefined();
287
+ });
288
+
289
+ test("a dedicated port opens a port and a shared one only turns access on", () => {
290
+ const [dedicated] = rconAccessSections({
291
+ portKey: RCON_ACCESS_PORT,
292
+ });
293
+ const [shared] = rconAccessSections({
294
+ portKey: "game",
295
+ });
296
+
297
+ if (dedicated?.layout !== BridgeLayout.Form || shared?.layout !== BridgeLayout.Form) {
298
+ throw new Error("the first section is the toggle form");
299
+ }
300
+
301
+ expect(dedicated.fields[0]?.help?.en).toContain("Opens the RCON port");
302
+ expect(shared.fields[0]?.help?.en).toContain("Turns remote access on");
303
+ });
304
+ });
@@ -0,0 +1,260 @@
1
+ import type { BridgeContext } from "../context";
2
+ import { BridgeKind, type LocalizedText } from "../core";
3
+ import type { ManifestHostRange } from "../manifest";
4
+ import type { BridgeDetailModule } from "../modules";
5
+ import {
6
+ BridgeConfirm,
7
+ BridgeControl,
8
+ type BridgeDetailBadge,
9
+ BridgeDetailFormat,
10
+ BridgeDetailTone,
11
+ BridgeFormTarget,
12
+ BridgeLayout,
13
+ type BridgeSection,
14
+ } from "../panel";
15
+
16
+ export const RCON_ACCESS_VARIABLE = "RCON_EXPOSED";
17
+
18
+ export const RCON_ACCESS_PORT = "rcon";
19
+
20
+ export const RCON_ACCESS_MODULE = "rconAccess";
21
+
22
+ export const RCON_ACCESS_ROTATE = "rotate";
23
+
24
+ export const RCON_ACCESS_HOST_RANGE: ManifestHostRange = {
25
+ default: 12_500,
26
+ min: 12_500,
27
+ max: 12_599,
28
+ };
29
+
30
+ export const RCON_PASSWORD_LENGTH = 24;
31
+
32
+ export const LOOPBACK_HOST = "127.0.0.1";
33
+
34
+ export const ANY_HOST = "0.0.0.0";
35
+
36
+ const FORM_SECTION_ID = "rcon-access";
37
+
38
+ const DETAIL_SECTION_ID = "rcon-access-detail";
39
+
40
+ const REFRESH_SECONDS = 30;
41
+
42
+ const DEFAULT_PROTOCOL: LocalizedText = {
43
+ ar: "RCON",
44
+ en: "RCON",
45
+ };
46
+
47
+ const ACCESS_TITLE: LocalizedText = {
48
+ ar: "الوصول عن بُعد",
49
+ en: "Remote access",
50
+ };
51
+
52
+ const ACCESS_DESCRIPTION: LocalizedText = {
53
+ ar: "الوصول عن بُعد يخلي أداة خارجية تتحكم بسيرفرك بكلمة المرور هذي. الكلمة تمشي على النت بدون تشفير، فاقفل الوصول لما ما فيه أداة تستخدمه، ولا تعطي الكلمة أحد. لو تسربت غيّرها على طول.",
54
+ en: "Remote access lets an external tool control your server with this password. The password crosses the internet in the clear, so turn remote access off when no tool is using it, and never share the password. Rotate it the moment it leaks.",
55
+ };
56
+
57
+ const OPEN_BADGE: BridgeDetailBadge = {
58
+ label: {
59
+ ar: "مفتوح",
60
+ en: "Open",
61
+ },
62
+ tone: BridgeDetailTone.Success,
63
+ };
64
+
65
+ const CLOSED_BADGE: BridgeDetailBadge = {
66
+ label: {
67
+ ar: "مقفول",
68
+ en: "Closed",
69
+ },
70
+ tone: BridgeDetailTone.Neutral,
71
+ };
72
+
73
+ const PENDING_BADGE: BridgeDetailBadge = {
74
+ label: {
75
+ ar: "يطبّق بعد إعادة التشغيل",
76
+ en: "Applies after restart",
77
+ },
78
+ tone: BridgeDetailTone.Warning,
79
+ };
80
+
81
+ export const rconExposed = (context: BridgeContext): boolean => {
82
+ return context.variable(RCON_ACCESS_VARIABLE) === "true";
83
+ };
84
+
85
+ export const rconBindHost = (context: BridgeContext): string => {
86
+ return rconExposed(context) ? ANY_HOST : LOOPBACK_HOST;
87
+ };
88
+
89
+ export interface RconAccessPassword {
90
+ value: string;
91
+ pending: boolean;
92
+ }
93
+
94
+ export interface RconAccessOptions {
95
+ protocol?: LocalizedText;
96
+ portKey?: string;
97
+ tools?: LocalizedText;
98
+ password(context: BridgeContext): Promise<RconAccessPassword | null>;
99
+ rotate(context: BridgeContext): Promise<void>;
100
+ }
101
+
102
+ export interface RconAccessSectionsOptions {
103
+ protocol?: LocalizedText;
104
+ portKey?: string;
105
+ }
106
+
107
+ const describeAccess = (tools: LocalizedText | undefined): LocalizedText => {
108
+ if (!tools) {
109
+ return ACCESS_DESCRIPTION;
110
+ }
111
+
112
+ return {
113
+ ar: `${ACCESS_DESCRIPTION.ar} ${tools.ar}`,
114
+ en: `${ACCESS_DESCRIPTION.en} ${tools.en}`,
115
+ };
116
+ };
117
+
118
+ const badgesFor = (exposed: boolean, pending: boolean): BridgeDetailBadge[] => {
119
+ const badges = [
120
+ exposed ? OPEN_BADGE : CLOSED_BADGE,
121
+ ];
122
+
123
+ if (pending) {
124
+ badges.push(PENDING_BADGE);
125
+ }
126
+
127
+ return badges;
128
+ };
129
+
130
+ const declaredPort = (context: BridgeContext, key: string): number | null => {
131
+ try {
132
+ return context.port(key);
133
+ } catch {
134
+ return null;
135
+ }
136
+ };
137
+
138
+ export const createRconAccess = (options: RconAccessOptions): BridgeDetailModule => {
139
+ const protocol = options.protocol ?? DEFAULT_PROTOCOL;
140
+ const portKey = options.portKey ?? RCON_ACCESS_PORT;
141
+
142
+ return {
143
+ kind: BridgeKind.Detail,
144
+ refreshSeconds: REFRESH_SECONDS,
145
+ async read(context) {
146
+ const password = await options.password(context);
147
+
148
+ if (!password) {
149
+ return null;
150
+ }
151
+
152
+ return {
153
+ id: RCON_ACCESS_MODULE,
154
+ title: ACCESS_TITLE,
155
+ subtitle: protocol,
156
+ description: describeAccess(options.tools),
157
+ image: null,
158
+ badges: badgesFor(rconExposed(context), password.pending),
159
+ stats: [
160
+ {
161
+ key: "port",
162
+ label: {
163
+ ar: `منفذ ${protocol.ar}`,
164
+ en: `${protocol.en} port`,
165
+ },
166
+ value: declaredPort(context, portKey),
167
+ format: BridgeDetailFormat.Number,
168
+ },
169
+ {
170
+ key: "password",
171
+ label: {
172
+ ar: `كلمة مرور ${protocol.ar}`,
173
+ en: `${protocol.en} password`,
174
+ },
175
+ value: password.value,
176
+ format: BridgeDetailFormat.Secret,
177
+ },
178
+ ],
179
+ links: [],
180
+ stale: false,
181
+ actions: [
182
+ RCON_ACCESS_ROTATE,
183
+ ],
184
+ };
185
+ },
186
+ actions: {
187
+ async [RCON_ACCESS_ROTATE](context) {
188
+ await options.rotate(context);
189
+
190
+ context.log("rotated the remote access password");
191
+
192
+ return null;
193
+ },
194
+ },
195
+ };
196
+ };
197
+
198
+ export const rconAccessSections = (options: RconAccessSectionsOptions = {}): BridgeSection[] => {
199
+ const protocol = options.protocol ?? DEFAULT_PROTOCOL;
200
+ const dedicated = (options.portKey ?? RCON_ACCESS_PORT) === RCON_ACCESS_PORT;
201
+
202
+ return [
203
+ {
204
+ layout: BridgeLayout.Form,
205
+ id: FORM_SECTION_ID,
206
+ title: ACCESS_TITLE,
207
+ target: BridgeFormTarget.Variables,
208
+ reinstall: false,
209
+ confirm: BridgeConfirm.Normal,
210
+ confirmText: {
211
+ ar: "نعيد تشغيل سيرفرك عشان يشتغل التغيير.",
212
+ en: "We restart your server so the change takes effect.",
213
+ },
214
+ fields: [
215
+ {
216
+ key: RCON_ACCESS_VARIABLE,
217
+ control: BridgeControl.Boolean,
218
+ label: ACCESS_TITLE,
219
+ help: dedicated
220
+ ? {
221
+ ar: `يفتح منفذ ${protocol.ar} عشان تتحكم بسيرفرك من أداة خارجية. المنفذ يطلع مع بقية عناوين سيرفرك بعد ما يرجع يشتغل.`,
222
+ en: `Opens the ${protocol.en} port so an external tool can control your server. The port appears with your server's other addresses once it is back up.`,
223
+ }
224
+ : {
225
+ ar: `يشغّل الوصول عن بُعد عشان تتحكم بسيرفرك من أداة خارجية على نفس عنوان اللعبة ومنفذها.`,
226
+ en: `Turns remote access on so an external tool can control your server over the same address and port as the game.`,
227
+ },
228
+ warning: {
229
+ ar: "كلمة المرور تمشي على النت بدون تشفير. اقفل الوصول لما ما تستخدمه.",
230
+ en: "The password crosses the internet in the clear. Turn it off when you are not using it.",
231
+ },
232
+ },
233
+ ],
234
+ },
235
+ {
236
+ layout: BridgeLayout.Detail,
237
+ id: DETAIL_SECTION_ID,
238
+ title: ACCESS_TITLE,
239
+ module: RCON_ACCESS_MODULE,
240
+ actions: [
241
+ {
242
+ id: RCON_ACCESS_ROTATE,
243
+ label: {
244
+ ar: "غيّر كلمة المرور",
245
+ en: "Rotate the password",
246
+ },
247
+ confirm: BridgeConfirm.Strong,
248
+ confirmText: {
249
+ ar: "كل أداة خارجية عندك بتوقف لين تحط الكلمة الجديدة فيها.",
250
+ en: "Every external tool you use stops working until you paste the new password into it.",
251
+ },
252
+ },
253
+ ],
254
+ empty: {
255
+ ar: "شغّل سيرفرك مرة عشان تتولد كلمة المرور.",
256
+ en: "Start your server once to generate the password.",
257
+ },
258
+ },
259
+ ];
260
+ };