@serverkgg/bridge 0.0.1
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.
- package/LICENSE.md +22 -0
- package/README.md +52 -0
- package/package.json +26 -0
- package/src/cli/compile.ts +75 -0
- package/src/cli/directorySource.ts +48 -0
- package/src/cli/main.ts +44 -0
- package/src/cli/validate.ts +94 -0
- package/src/codecs/index.ts +3 -0
- package/src/codecs/json.ts +42 -0
- package/src/codecs/properties.ts +75 -0
- package/src/codecs/ueIni.ts +235 -0
- package/src/context/bridgeContext.ts +120 -0
- package/src/context/index.ts +1 -0
- package/src/core/bridgeAction.ts +13 -0
- package/src/core/bridgeId.ts +3 -0
- package/src/core/bridgeKind.ts +11 -0
- package/src/core/bridgePort.ts +4 -0
- package/src/core/bridgeRuntime.ts +13 -0
- package/src/core/bridgeSecretError.ts +10 -0
- package/src/core/bridgeStream.ts +4 -0
- package/src/core/bridgeValue.ts +19 -0
- package/src/core/index.ts +8 -0
- package/src/main.ts +5 -0
- package/src/manifest/assets.ts +102 -0
- package/src/manifest/category.ts +10 -0
- package/src/manifest/checks.ts +50 -0
- package/src/manifest/dimensions.ts +106 -0
- package/src/manifest/guide.ts +36 -0
- package/src/manifest/image.ts +1 -0
- package/src/manifest/index.ts +9 -0
- package/src/manifest/localized.ts +8 -0
- package/src/manifest/manifest.ts +170 -0
- package/src/manifest/source.ts +89 -0
- package/src/modules/bridge.ts +53 -0
- package/src/modules/bridgeDriver.ts +19 -0
- package/src/modules/bridgeModule.ts +114 -0
- package/src/modules/index.ts +3 -0
- package/src/panel/bridgeConfirm.ts +5 -0
- package/src/panel/bridgeControl.ts +7 -0
- package/src/panel/bridgeFormTarget.ts +4 -0
- package/src/panel/bridgeIcon.ts +17 -0
- package/src/panel/bridgeLayout.ts +6 -0
- package/src/panel/bridgePanel.ts +107 -0
- package/src/panel/index.ts +6 -0
- package/src/probes/a2s.ts +170 -0
- package/src/probes/index.ts +1 -0
- package/src/protocol/bridgeConfig.ts +13 -0
- package/src/protocol/bridgeFrame.ts +91 -0
- package/src/protocol/bridgeLine.ts +40 -0
- package/src/protocol/bridgeManifest.ts +19 -0
- package/src/protocol/bridgeVersion.ts +11 -0
- package/src/protocol/index.ts +7 -0
- package/src/runtime/bridgeCodec.ts +78 -0
- package/src/runtime/bridgeConfigReader.ts +119 -0
- package/src/runtime/bridgeContext.ts +96 -0
- package/src/runtime/bridgeDriver.ts +179 -0
- package/src/runtime/bridgeEvents.ts +71 -0
- package/src/runtime/bridgeExec.ts +143 -0
- package/src/runtime/bridgeFiles.ts +412 -0
- package/src/runtime/bridgeLog.ts +15 -0
- package/src/runtime/bridgeNet.ts +59 -0
- package/src/runtime/bridgePath.ts +132 -0
- package/src/runtime/bridgePrivilege.ts +99 -0
- package/src/runtime/bridgeProbe.ts +14 -0
- package/src/runtime/bridgeRegistry.ts +311 -0
- package/src/runtime/bridgeServer.ts +233 -0
- package/src/runtime/bridgeState.ts +40 -0
- package/src/runtime/bridgeSupervisor.ts +498 -0
- package/src/runtime/bridgeZip.ts +122 -0
- package/src/runtime/index.ts +18 -0
- package/src/runtime/serve.ts +294 -0
- package/src/utils/glob.ts +62 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { chown, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import type { BridgeDownloadOptions, BridgeFileEntry, BridgeFiles } from "../context";
|
|
4
|
+
import { compileGlob } from "../utils";
|
|
5
|
+
import type { BridgeLogger } from "./bridgeLog";
|
|
6
|
+
import { BridgePathError, resolveWithin, toSegments } from "./bridgePath";
|
|
7
|
+
import { isPrivileged } from "./bridgePrivilege";
|
|
8
|
+
import { readZip } from "./bridgeZip";
|
|
9
|
+
|
|
10
|
+
const READ_LIMIT = 4 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
const DOWNLOAD_LIMIT = 512 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
|
15
|
+
|
|
16
|
+
const ARCHIVE_LIMIT = 256 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
const WALK_DEPTH = 12;
|
|
19
|
+
|
|
20
|
+
const WALK_LIMIT = 4096;
|
|
21
|
+
|
|
22
|
+
const MILESTONE_STEP = 25;
|
|
23
|
+
|
|
24
|
+
const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
|
|
25
|
+
|
|
26
|
+
const DIGEST_LENGTHS: Record<string, number> = {
|
|
27
|
+
sha1: 40,
|
|
28
|
+
sha256: 64,
|
|
29
|
+
sha512: 128,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface BridgeFilesOptions {
|
|
33
|
+
root: string;
|
|
34
|
+
uid: number;
|
|
35
|
+
gid: number;
|
|
36
|
+
onLog: BridgeLogger;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parseDigest = (digest: string) => {
|
|
40
|
+
const [algo, expected] = digest.split(":");
|
|
41
|
+
|
|
42
|
+
if (!algo || !expected || DIGEST_LENGTHS[algo] !== expected.length) {
|
|
43
|
+
throw new BridgePathError("invalid", `digest "${digest}" is not supported`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
algo: algo as "sha1" | "sha256" | "sha512",
|
|
48
|
+
expected,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const directorySize = async (path: string, depth: number): Promise<number> => {
|
|
53
|
+
if (depth > WALK_DEPTH) {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let entries: Dirent[];
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
entries = await readdir(path, {
|
|
61
|
+
withFileTypes: true,
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let total = 0;
|
|
68
|
+
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.isSymbolicLink()) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const absolute = `${path}/${entry.name}`;
|
|
75
|
+
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
total += await directorySize(absolute, depth + 1);
|
|
78
|
+
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (entry.isFile()) {
|
|
83
|
+
total += Bun.file(absolute).size;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return total;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const walk = async (directory: string, prefix: string, depth: number, out: BridgeFileEntry[]) => {
|
|
91
|
+
if (depth > WALK_DEPTH || out.length >= WALK_LIMIT) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let entries: Dirent[];
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
entries = await readdir(directory, {
|
|
99
|
+
withFileTypes: true,
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (out.length >= WALK_LIMIT) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (entry.isSymbolicLink()) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const relative = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`;
|
|
115
|
+
const absolute = `${directory}/${entry.name}`;
|
|
116
|
+
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
await walk(absolute, relative, depth + 1, out);
|
|
119
|
+
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!entry.isFile()) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const file = Bun.file(absolute);
|
|
128
|
+
|
|
129
|
+
out.push({
|
|
130
|
+
path: relative,
|
|
131
|
+
name: entry.name,
|
|
132
|
+
directory: false,
|
|
133
|
+
sizeBytes: file.size,
|
|
134
|
+
modifiedAt: new Date(file.lastModified).toISOString(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
140
|
+
const own = async (path: string) => {
|
|
141
|
+
if (!isPrivileged()) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await chown(path, options.uid, options.gid);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const ensureDirectory = async (relative: string) => {
|
|
149
|
+
const segments = toSegments(relative);
|
|
150
|
+
|
|
151
|
+
let current = options.root;
|
|
152
|
+
|
|
153
|
+
for (const segment of segments) {
|
|
154
|
+
current = `${current}/${segment}`;
|
|
155
|
+
|
|
156
|
+
await mkdir(current, {
|
|
157
|
+
recursive: true,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await own(current);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return current;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const ensureParent = async (relative: string) => {
|
|
167
|
+
const segments = toSegments(relative);
|
|
168
|
+
|
|
169
|
+
await ensureDirectory(segments.slice(0, -1).join("/"));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
async read(path) {
|
|
174
|
+
const resolved = await resolveWithin(options.root, path);
|
|
175
|
+
|
|
176
|
+
if (!resolved.exists || resolved.isDirectory) {
|
|
177
|
+
throw new BridgePathError("missing", `path "${path}" is not a readable file`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (resolved.sizeBytes > READ_LIMIT) {
|
|
181
|
+
throw new BridgePathError("invalid", `path "${path}" is larger than the read limit`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return await Bun.file(resolved.target).text();
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async write(path, content) {
|
|
188
|
+
await ensureParent(path);
|
|
189
|
+
|
|
190
|
+
const resolved = await resolveWithin(options.root, path);
|
|
191
|
+
|
|
192
|
+
await writeFile(resolved.target, content, "utf8");
|
|
193
|
+
await own(resolved.target);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async list(glob) {
|
|
197
|
+
const pattern = compileGlob(glob);
|
|
198
|
+
const entries: BridgeFileEntry[] = [];
|
|
199
|
+
|
|
200
|
+
await walk(options.root, "", 0, entries);
|
|
201
|
+
|
|
202
|
+
return entries.filter((entry) => pattern.test(entry.path));
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async remove(path) {
|
|
206
|
+
const resolved = await resolveWithin(options.root, path);
|
|
207
|
+
|
|
208
|
+
if (resolved.isRoot) {
|
|
209
|
+
throw new BridgePathError("invalid", "the server directory cannot be removed");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await rm(resolved.target, {
|
|
213
|
+
force: true,
|
|
214
|
+
recursive: true,
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async move(from, to) {
|
|
219
|
+
const source = await resolveWithin(options.root, from);
|
|
220
|
+
const destination = await resolveWithin(options.root, to);
|
|
221
|
+
|
|
222
|
+
if (source.isRoot || destination.isRoot) {
|
|
223
|
+
throw new BridgePathError("invalid", "the server directory cannot be moved");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!source.exists) {
|
|
227
|
+
throw new BridgePathError("missing", `"${from}" does not exist`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (destination.exists) {
|
|
231
|
+
throw new BridgePathError("invalid", `"${to}" already exists`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await rename(source.target, destination.target);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async ensure(...paths) {
|
|
238
|
+
for (const path of paths) {
|
|
239
|
+
await ensureDirectory(path);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async exists(path) {
|
|
244
|
+
try {
|
|
245
|
+
return (await resolveWithin(options.root, path)).exists;
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
async size(path) {
|
|
252
|
+
const resolved = await resolveWithin(options.root, path);
|
|
253
|
+
|
|
254
|
+
if (!resolved.exists) {
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return resolved.isDirectory ? await directorySize(resolved.target, 0) : resolved.sizeBytes;
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async download(path, url, downloadOptions: BridgeDownloadOptions = {}) {
|
|
262
|
+
const remote = new URL(url);
|
|
263
|
+
|
|
264
|
+
if (remote.protocol !== "https:") {
|
|
265
|
+
throw new BridgePathError("invalid", `url "${url}" is not https`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const limit = downloadOptions.sizeBytes ?? DOWNLOAD_LIMIT;
|
|
269
|
+
|
|
270
|
+
if (limit > DOWNLOAD_LIMIT) {
|
|
271
|
+
throw new BridgePathError("invalid", `download of ${limit} bytes exceeds the limit`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const response = await fetch(url, {
|
|
275
|
+
headers: {
|
|
276
|
+
"user-agent": USER_AGENT,
|
|
277
|
+
},
|
|
278
|
+
redirect: "follow",
|
|
279
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!response.ok || !response.body) {
|
|
283
|
+
throw new BridgePathError("missing", `${url} answered ${response.status}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await ensureParent(path);
|
|
287
|
+
|
|
288
|
+
const resolved = await resolveWithin(options.root, path);
|
|
289
|
+
const staging = `${resolved.target}.download`;
|
|
290
|
+
|
|
291
|
+
const declared = Number(response.headers.get("content-length"));
|
|
292
|
+
const totalBytes =
|
|
293
|
+
Number.isSafeInteger(declared) && declared > 0 ? declared : (downloadOptions.sizeBytes ?? null);
|
|
294
|
+
|
|
295
|
+
const hasher = downloadOptions.digest ? new Bun.CryptoHasher(parseDigest(downloadOptions.digest).algo) : null;
|
|
296
|
+
const writer = Bun.file(staging).writer();
|
|
297
|
+
|
|
298
|
+
options.onLog("downloading", {
|
|
299
|
+
file: resolved.relative,
|
|
300
|
+
sizeBytes: totalBytes,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
let readBytes = 0;
|
|
304
|
+
|
|
305
|
+
let milestone = 0;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
for await (const chunk of response.body) {
|
|
309
|
+
readBytes += chunk.byteLength;
|
|
310
|
+
|
|
311
|
+
if (readBytes > limit) {
|
|
312
|
+
throw new BridgePathError("invalid", `${url} returned more than ${limit} bytes`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
hasher?.update(chunk);
|
|
316
|
+
writer.write(chunk);
|
|
317
|
+
|
|
318
|
+
await writer.flush();
|
|
319
|
+
|
|
320
|
+
if (totalBytes !== null) {
|
|
321
|
+
const reached = Math.floor((readBytes / totalBytes) * 100);
|
|
322
|
+
|
|
323
|
+
if (reached >= milestone + MILESTONE_STEP && reached < 100) {
|
|
324
|
+
milestone = Math.floor(reached / MILESTONE_STEP) * MILESTONE_STEP;
|
|
325
|
+
|
|
326
|
+
options.onLog("downloading", {
|
|
327
|
+
file: resolved.relative,
|
|
328
|
+
percent: milestone,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await writer.end();
|
|
335
|
+
|
|
336
|
+
if (downloadOptions.sizeBytes !== undefined && readBytes !== downloadOptions.sizeBytes) {
|
|
337
|
+
throw new BridgePathError(
|
|
338
|
+
"invalid",
|
|
339
|
+
`${url} returned ${readBytes} bytes, expected ${downloadOptions.sizeBytes}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (hasher && downloadOptions.digest) {
|
|
344
|
+
const { expected } = parseDigest(downloadOptions.digest);
|
|
345
|
+
|
|
346
|
+
if (hasher.digest("hex") !== expected) {
|
|
347
|
+
throw new BridgePathError("invalid", `${url} failed digest verification`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
writer.end();
|
|
352
|
+
|
|
353
|
+
await rm(staging, {
|
|
354
|
+
force: true,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await rename(staging, resolved.target);
|
|
361
|
+
await own(resolved.target);
|
|
362
|
+
|
|
363
|
+
options.onLog("downloaded", {
|
|
364
|
+
file: resolved.relative,
|
|
365
|
+
sizeBytes: readBytes,
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async extract(path, destination, extensions) {
|
|
370
|
+
const resolved = await resolveWithin(options.root, path);
|
|
371
|
+
|
|
372
|
+
if (!resolved.exists || resolved.isDirectory) {
|
|
373
|
+
throw new BridgePathError("missing", `path "${path}" is not a readable archive`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (resolved.sizeBytes > ARCHIVE_LIMIT) {
|
|
377
|
+
throw new BridgePathError("invalid", `archive "${path}" is larger than the extract limit`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const allowed = extensions?.map((extension) => extension.toLowerCase());
|
|
381
|
+
const target = await ensureDirectory(destination);
|
|
382
|
+
const buffer = Buffer.from(await Bun.file(resolved.target).arrayBuffer());
|
|
383
|
+
const written: string[] = [];
|
|
384
|
+
|
|
385
|
+
for (const entry of readZip(buffer)) {
|
|
386
|
+
const name = entry.name.split("/").at(-1) ?? "";
|
|
387
|
+
const extension = name.split(".").at(-1)?.toLowerCase() ?? "";
|
|
388
|
+
|
|
389
|
+
if (name.length === 0 || name.startsWith(".")) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (allowed !== undefined && !allowed.includes(extension)) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const relative = toSegments(destination).length === 0 ? name : `${toSegments(destination).join("/")}/${name}`;
|
|
398
|
+
|
|
399
|
+
await Bun.write(`${target}/${name}`, entry.data);
|
|
400
|
+
await own(`${target}/${name}`);
|
|
401
|
+
|
|
402
|
+
written.push(relative);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (written.length === 0) {
|
|
406
|
+
throw new BridgePathError("invalid", `archive "${path}" contained no matching files`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return written;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type BridgeLogger = (message: string, fields?: Record<string, unknown>) => void;
|
|
2
|
+
|
|
3
|
+
export const log: BridgeLogger = (message, fields = {}) => {
|
|
4
|
+
process.stdout.write(
|
|
5
|
+
`${JSON.stringify({
|
|
6
|
+
source: "bridge",
|
|
7
|
+
message,
|
|
8
|
+
...fields,
|
|
9
|
+
})}\n`,
|
|
10
|
+
);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const errorText = (error: unknown) => {
|
|
14
|
+
return error instanceof Error ? error.message : String(error);
|
|
15
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { BridgeNet, BridgeRequestOptions } from "../context";
|
|
2
|
+
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
|
+
|
|
5
|
+
const RESPONSE_LIMIT = 8 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
|
|
8
|
+
|
|
9
|
+
export class BridgeNetError extends Error {
|
|
10
|
+
readonly status: number | null;
|
|
11
|
+
|
|
12
|
+
constructor(status: number | null, message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
|
|
15
|
+
this.name = "BridgeNetError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const createBridgeNet = (): BridgeNet => {
|
|
21
|
+
const request = async (url: string, options: BridgeRequestOptions = {}) => {
|
|
22
|
+
const remote = new URL(url);
|
|
23
|
+
|
|
24
|
+
if (remote.protocol !== "https:") {
|
|
25
|
+
throw new BridgeNetError(null, `"${url}" is not https`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
headers: {
|
|
30
|
+
"user-agent": USER_AGENT,
|
|
31
|
+
...options.headers,
|
|
32
|
+
},
|
|
33
|
+
redirect: "follow",
|
|
34
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new BridgeNetError(response.status, `${url} answered ${response.status}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const body = await response.text();
|
|
42
|
+
|
|
43
|
+
if (body.length > RESPONSE_LIMIT) {
|
|
44
|
+
throw new BridgeNetError(null, `${url} returned more than ${RESPONSE_LIMIT} bytes`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return body;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async json<Result>(url: string, options?: BridgeRequestOptions) {
|
|
52
|
+
return JSON.parse(await request(url, options)) as Result;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async text(url: string, options?: BridgeRequestOptions) {
|
|
56
|
+
return await request(url, options);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export class BridgePathError extends Error {
|
|
4
|
+
readonly kind: "invalid" | "missing";
|
|
5
|
+
|
|
6
|
+
constructor(kind: "invalid" | "missing", message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
|
|
9
|
+
this.name = "BridgePathError";
|
|
10
|
+
this.kind = kind;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ResolvedPath {
|
|
15
|
+
target: string;
|
|
16
|
+
name: string;
|
|
17
|
+
relative: string;
|
|
18
|
+
root: string;
|
|
19
|
+
isRoot: boolean;
|
|
20
|
+
exists: boolean;
|
|
21
|
+
isDirectory: boolean;
|
|
22
|
+
sizeBytes: number;
|
|
23
|
+
modifiedAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const toSegments = (rawPath: string) => {
|
|
27
|
+
const segments = rawPath.split("/").filter((segment) => segment.length > 0 && segment !== ".");
|
|
28
|
+
|
|
29
|
+
if (segments.some((segment) => segment === ".." || segment.includes("\\"))) {
|
|
30
|
+
throw new BridgePathError("invalid", `path "${rawPath}" is not allowed`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return segments;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const resolveWithin = async (root: string, rawPath: string): Promise<ResolvedPath> => {
|
|
37
|
+
const segments = toSegments(rawPath);
|
|
38
|
+
|
|
39
|
+
let base: string;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
base = await realpath(root);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new BridgePathError("missing", `the server directory "${root}" does not exist`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (segments.length === 0) {
|
|
48
|
+
return {
|
|
49
|
+
target: base,
|
|
50
|
+
name: "",
|
|
51
|
+
relative: "",
|
|
52
|
+
root: base,
|
|
53
|
+
isRoot: true,
|
|
54
|
+
exists: true,
|
|
55
|
+
isDirectory: true,
|
|
56
|
+
sizeBytes: 0,
|
|
57
|
+
modifiedAt: "",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const name = segments.at(-1) ?? "";
|
|
62
|
+
const parentSegments = segments.slice(0, -1);
|
|
63
|
+
|
|
64
|
+
let parent: string;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
parent = await realpath(
|
|
68
|
+
[
|
|
69
|
+
base,
|
|
70
|
+
...parentSegments,
|
|
71
|
+
].join("/"),
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
return {
|
|
75
|
+
target: [
|
|
76
|
+
base,
|
|
77
|
+
...segments,
|
|
78
|
+
].join("/"),
|
|
79
|
+
name,
|
|
80
|
+
relative: segments.join("/"),
|
|
81
|
+
root: base,
|
|
82
|
+
isRoot: false,
|
|
83
|
+
exists: false,
|
|
84
|
+
isDirectory: false,
|
|
85
|
+
sizeBytes: 0,
|
|
86
|
+
modifiedAt: "",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (parent !== base && !parent.startsWith(`${base}/`)) {
|
|
91
|
+
throw new BridgePathError("invalid", `path "${rawPath}" escapes the server directory`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const target = `${parent}/${name}`;
|
|
95
|
+
const relative = segments.join("/");
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const info = await lstat(target);
|
|
99
|
+
|
|
100
|
+
if (info.isSymbolicLink()) {
|
|
101
|
+
throw new BridgePathError("invalid", `path "${rawPath}" is a symlink`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
target,
|
|
106
|
+
name,
|
|
107
|
+
relative,
|
|
108
|
+
root: base,
|
|
109
|
+
isRoot: false,
|
|
110
|
+
exists: true,
|
|
111
|
+
isDirectory: info.isDirectory(),
|
|
112
|
+
sizeBytes: info.size,
|
|
113
|
+
modifiedAt: info.mtime.toISOString(),
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof BridgePathError) {
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
target,
|
|
122
|
+
name,
|
|
123
|
+
relative,
|
|
124
|
+
root: base,
|
|
125
|
+
isRoot: false,
|
|
126
|
+
exists: false,
|
|
127
|
+
isDirectory: false,
|
|
128
|
+
sizeBytes: 0,
|
|
129
|
+
modifiedAt: "",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const UID_SETTLE_MS = 3000;
|
|
2
|
+
|
|
3
|
+
const UID_POLL_MS = 25;
|
|
4
|
+
|
|
5
|
+
const SETPRIV = "setpriv";
|
|
6
|
+
|
|
7
|
+
const PROBE_COMMAND = [
|
|
8
|
+
"sleep",
|
|
9
|
+
"5",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
let nativeUid: boolean | null = null;
|
|
13
|
+
|
|
14
|
+
export const isPrivileged = () => {
|
|
15
|
+
return typeof process.getuid === "function" && process.getuid() === 0;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const readEffectiveUid = async (pid: number) => {
|
|
19
|
+
try {
|
|
20
|
+
const status = await Bun.file(`/proc/${pid}/status`).text();
|
|
21
|
+
const line = status.split("\n").find((entry) => entry.startsWith("Uid:"));
|
|
22
|
+
const effective = line?.split(/\s+/).at(2);
|
|
23
|
+
|
|
24
|
+
return effective === undefined ? null : Number(effective);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const settleUid = async (pid: number, expected: number) => {
|
|
31
|
+
let observed = await readEffectiveUid(pid);
|
|
32
|
+
|
|
33
|
+
for (let waited = 0; observed !== expected && observed !== null && waited < UID_SETTLE_MS; waited += UID_POLL_MS) {
|
|
34
|
+
await Bun.sleep(UID_POLL_MS);
|
|
35
|
+
|
|
36
|
+
observed = await readEffectiveUid(pid);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return observed;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const spawnWithUid = (command: string[], uid: number, gid: number) => {
|
|
43
|
+
const credentials: object = {
|
|
44
|
+
uid,
|
|
45
|
+
gid,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return Bun.spawn(command, {
|
|
49
|
+
...credentials,
|
|
50
|
+
stderr: "ignore",
|
|
51
|
+
stdin: "ignore",
|
|
52
|
+
stdout: "ignore",
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const probeNativeUid = async (uid: number, gid: number) => {
|
|
57
|
+
if (nativeUid !== null) {
|
|
58
|
+
return nativeUid;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const probe = spawnWithUid(PROBE_COMMAND, uid, gid);
|
|
63
|
+
const observed = await settleUid(probe.pid, uid);
|
|
64
|
+
|
|
65
|
+
probe.kill("SIGKILL");
|
|
66
|
+
|
|
67
|
+
nativeUid = observed === uid;
|
|
68
|
+
} catch {
|
|
69
|
+
nativeUid = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return nativeUid;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const setprivArgv = (command: string[], uid: number, gid: number) => {
|
|
76
|
+
if (!Bun.which(SETPRIV)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`this runtime ignores the spawn uid option and ${SETPRIV} is missing from the game image, so the bridge cannot start the game as uid ${uid}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return [
|
|
83
|
+
SETPRIV,
|
|
84
|
+
`--reuid=${uid}`,
|
|
85
|
+
`--regid=${gid}`,
|
|
86
|
+
"--clear-groups",
|
|
87
|
+
"--",
|
|
88
|
+
...command,
|
|
89
|
+
];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const unprivilegedArgv = async (command: string[], uid: number, gid: number) => {
|
|
93
|
+
const native = await probeNativeUid(uid, gid);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
argv: native ? command : setprivArgv(command, uid, gid),
|
|
97
|
+
native,
|
|
98
|
+
};
|
|
99
|
+
};
|