@bbigbang/server-ops 0.1.0
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/dist/index.d.ts +26 -0
- package/dist/index.js +591 -0
- package/package.json +32 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
type JoinOptions = {
|
|
3
|
+
hubUrl: string;
|
|
4
|
+
token: string;
|
|
5
|
+
serverId: string;
|
|
6
|
+
serverSlug: string;
|
|
7
|
+
serverName: string;
|
|
8
|
+
version: string;
|
|
9
|
+
installPackages: string[];
|
|
10
|
+
port: number;
|
|
11
|
+
service: 'systemd' | 'tmux' | 'none';
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
};
|
|
14
|
+
type BridgeOptions = {
|
|
15
|
+
hubUrl: string;
|
|
16
|
+
token: string;
|
|
17
|
+
credentialPath: string;
|
|
18
|
+
serverId: string;
|
|
19
|
+
serverSlug: string;
|
|
20
|
+
serverName: string;
|
|
21
|
+
version: string;
|
|
22
|
+
coreUrl: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function joinServer(options: JoinOptions): Promise<void>;
|
|
25
|
+
export declare function runBridge(options: BridgeOptions): Promise<void>;
|
|
26
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import WebSocket from 'ws';
|
|
10
|
+
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/u;
|
|
11
|
+
const MAX_BRIDGE_HTTP_BODY_BYTES = 25 * 1024 * 1024;
|
|
12
|
+
async function main(argv = process.argv.slice(2)) {
|
|
13
|
+
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
|
14
|
+
printUsage();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const [scope, command, ...rest] = argv;
|
|
18
|
+
if (scope === 'server' && command === 'join') {
|
|
19
|
+
await joinServer(parseJoinOptions(rest));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (scope === 'server' && command === 'bridge') {
|
|
23
|
+
await runBridge(parseBridgeOptions(rest));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
{
|
|
27
|
+
throw new Error(`Unknown command: ${argv.join(' ')}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function joinServer(options) {
|
|
31
|
+
validateJoinOptions(options);
|
|
32
|
+
const home = os.homedir();
|
|
33
|
+
const coreHome = process.env.BIGBANG_HOME?.trim() || path.join(home, '.bigbang');
|
|
34
|
+
const nodeHome = process.env.BIGBANG_NODE_HOME?.trim() || path.join(home, '.bigbang-node');
|
|
35
|
+
const coreConfigPath = path.join(coreHome, 'config.json');
|
|
36
|
+
const nodeEnvPath = path.join(nodeHome, 'server.env');
|
|
37
|
+
const legacyNodeEnvPath = path.join(home, '.bigbang-node.env');
|
|
38
|
+
const bridgeEnvPath = path.join(coreHome, 'bridge.env');
|
|
39
|
+
const bridgeCredentialPath = path.join(coreHome, 'bridge-credential.json');
|
|
40
|
+
const coreWsUrl = `ws://127.0.0.1:${options.port}`;
|
|
41
|
+
const coreHttpUrl = `http://127.0.0.1:${options.port}`;
|
|
42
|
+
const coreConfig = {
|
|
43
|
+
serverId: options.serverId,
|
|
44
|
+
serverSlug: options.serverSlug,
|
|
45
|
+
serverName: options.serverName,
|
|
46
|
+
publicServerUrl: stripTrailingSlash(options.hubUrl),
|
|
47
|
+
publicBasePath: `/s/${options.serverSlug}`,
|
|
48
|
+
agentBridgeServerUrl: coreHttpUrl,
|
|
49
|
+
webHost: '127.0.0.1',
|
|
50
|
+
webPort: options.port,
|
|
51
|
+
workspaceRoot: path.join(coreHome, 'workspace'),
|
|
52
|
+
dbPath: path.join(coreHome, 'data', 'gateway.db'),
|
|
53
|
+
internalAgentAuthToken: cryptoRandomToken(),
|
|
54
|
+
nodeDispatchAckTimeoutMs: 5_000,
|
|
55
|
+
};
|
|
56
|
+
const nodeEnv = [
|
|
57
|
+
envLine('CORE_URL', coreWsUrl),
|
|
58
|
+
envLine('NODE_ID', `${options.serverSlug}-node-1`),
|
|
59
|
+
envLine('NODE_HOSTNAME', os.hostname()),
|
|
60
|
+
envLine('DB_PATH', path.join(nodeHome, 'db.sqlite')),
|
|
61
|
+
envLine('WORKSPACE_ROOT', path.join(nodeHome, 'workspace')),
|
|
62
|
+
'',
|
|
63
|
+
].join('\n');
|
|
64
|
+
const bridgeEnv = [
|
|
65
|
+
envLine('HUB_URL', stripTrailingSlash(options.hubUrl)),
|
|
66
|
+
envLine('TOKEN', options.token),
|
|
67
|
+
envLine('BRIDGE_CREDENTIAL_PATH', bridgeCredentialPath),
|
|
68
|
+
envLine('SERVER_ID', options.serverId),
|
|
69
|
+
envLine('SERVER_SLUG', options.serverSlug),
|
|
70
|
+
envLine('SERVER_NAME', options.serverName),
|
|
71
|
+
envLine('VERSION', options.version),
|
|
72
|
+
envLine('CORE_URL', coreHttpUrl),
|
|
73
|
+
'',
|
|
74
|
+
].join('\n');
|
|
75
|
+
if (options.dryRun) {
|
|
76
|
+
console.log(JSON.stringify({
|
|
77
|
+
action: 'dry-run',
|
|
78
|
+
installPackages: packageInstallList(options),
|
|
79
|
+
coreConfigPath,
|
|
80
|
+
coreConfig,
|
|
81
|
+
nodeEnvPath,
|
|
82
|
+
legacyNodeEnvPath,
|
|
83
|
+
nodeEnv,
|
|
84
|
+
bridgeEnvPath,
|
|
85
|
+
bridgeEnv,
|
|
86
|
+
service: options.service,
|
|
87
|
+
bridgeUrl: buildBridgeUrl(options.hubUrl, options.token),
|
|
88
|
+
}, null, 2));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
run('npm', ['install', '-g', ...packageInstallList(options)]);
|
|
92
|
+
fs.mkdirSync(path.dirname(coreConfigPath), { recursive: true });
|
|
93
|
+
fs.mkdirSync(path.dirname(nodeEnvPath), { recursive: true });
|
|
94
|
+
fs.writeFileSync(coreConfigPath, `${JSON.stringify(coreConfig, null, 2)}\n`, 'utf8');
|
|
95
|
+
fs.writeFileSync(nodeEnvPath, nodeEnv, 'utf8');
|
|
96
|
+
fs.writeFileSync(legacyNodeEnvPath, nodeEnv, 'utf8');
|
|
97
|
+
fs.writeFileSync(bridgeEnvPath, bridgeEnv, 'utf8');
|
|
98
|
+
await startServices(options, { coreHome, nodeEnvPath, bridgeEnvPath });
|
|
99
|
+
console.log(JSON.stringify({
|
|
100
|
+
ok: true,
|
|
101
|
+
status: 'services-started',
|
|
102
|
+
registration: 'pending-bridge-connection',
|
|
103
|
+
serverSlug: options.serverSlug,
|
|
104
|
+
bridge: buildBridgeUrl(options.hubUrl, options.token),
|
|
105
|
+
bridgeCredentialPath,
|
|
106
|
+
}, null, 2));
|
|
107
|
+
}
|
|
108
|
+
function parseJoinOptions(argv) {
|
|
109
|
+
const values = new Map();
|
|
110
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
111
|
+
const arg = argv[index];
|
|
112
|
+
if (!arg.startsWith('--'))
|
|
113
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
114
|
+
if (arg === '--dry-run') {
|
|
115
|
+
values.set('dry-run', true);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const value = argv[index + 1];
|
|
119
|
+
if (!value || value.startsWith('--'))
|
|
120
|
+
throw new Error(`${arg} requires a value.`);
|
|
121
|
+
values.set(arg.slice(2), value);
|
|
122
|
+
index += 1;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
hubUrl: readString(values, 'hub-url', true),
|
|
126
|
+
token: readString(values, 'token', true),
|
|
127
|
+
serverId: readString(values, 'server-id', true),
|
|
128
|
+
serverSlug: readString(values, 'server-slug', true),
|
|
129
|
+
serverName: readString(values, 'server-name', true),
|
|
130
|
+
version: readString(values, 'version', true),
|
|
131
|
+
installPackages: parseInstallPackages(readString(values, 'install-packages', false)),
|
|
132
|
+
port: Number(readString(values, 'port', false) || '3100'),
|
|
133
|
+
service: normalizeService(readString(values, 'service', false) || 'systemd'),
|
|
134
|
+
dryRun: values.get('dry-run') === true,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function parseBridgeOptions(argv) {
|
|
138
|
+
const values = new Map();
|
|
139
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
140
|
+
const arg = argv[index];
|
|
141
|
+
if (!arg.startsWith('--'))
|
|
142
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
143
|
+
const value = argv[index + 1];
|
|
144
|
+
if (!value || value.startsWith('--'))
|
|
145
|
+
throw new Error(`${arg} requires a value.`);
|
|
146
|
+
values.set(arg.slice(2), value);
|
|
147
|
+
index += 1;
|
|
148
|
+
}
|
|
149
|
+
const options = {
|
|
150
|
+
hubUrl: readString(values, 'hub-url', false) || process.env.HUB_URL?.trim() || '',
|
|
151
|
+
token: readString(values, 'token', false) || process.env.TOKEN?.trim() || '',
|
|
152
|
+
credentialPath: readString(values, 'credential-path', false) || process.env.BRIDGE_CREDENTIAL_PATH?.trim() || path.join(os.homedir(), '.bigbang', 'bridge-credential.json'),
|
|
153
|
+
serverId: readString(values, 'server-id', false) || process.env.SERVER_ID?.trim() || '',
|
|
154
|
+
serverSlug: readString(values, 'server-slug', false) || process.env.SERVER_SLUG?.trim() || '',
|
|
155
|
+
serverName: readString(values, 'server-name', false) || process.env.SERVER_NAME?.trim() || '',
|
|
156
|
+
version: readString(values, 'version', false) || process.env.VERSION?.trim() || '',
|
|
157
|
+
coreUrl: readString(values, 'core-url', false) || process.env.CORE_URL?.trim() || 'http://127.0.0.1:3100',
|
|
158
|
+
};
|
|
159
|
+
validateBridgeOptions(options);
|
|
160
|
+
return options;
|
|
161
|
+
}
|
|
162
|
+
function readString(values, key, required) {
|
|
163
|
+
const value = values.get(key);
|
|
164
|
+
if (typeof value === 'string' && value.trim())
|
|
165
|
+
return value.trim();
|
|
166
|
+
if (required)
|
|
167
|
+
throw new Error(`--${key} is required.`);
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
function normalizeService(value) {
|
|
171
|
+
if (value === 'systemd' || value === 'tmux' || value === 'none')
|
|
172
|
+
return value;
|
|
173
|
+
throw new Error('--service must be systemd, tmux, or none.');
|
|
174
|
+
}
|
|
175
|
+
function validateJoinOptions(options) {
|
|
176
|
+
if (!SLUG_RE.test(options.serverSlug))
|
|
177
|
+
throw new Error('--server-slug must be a safe path segment.');
|
|
178
|
+
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)
|
|
179
|
+
throw new Error('--port must be 1..65535.');
|
|
180
|
+
normalizeHttpUrl(options.hubUrl, '--hub-url');
|
|
181
|
+
}
|
|
182
|
+
function validateBridgeOptions(options) {
|
|
183
|
+
if (!SLUG_RE.test(options.serverSlug))
|
|
184
|
+
throw new Error('--server-slug must be a safe path segment.');
|
|
185
|
+
for (const field of ['hubUrl', 'token', 'serverId', 'serverName', 'version']) {
|
|
186
|
+
if (!options[field])
|
|
187
|
+
throw new Error(`--${field.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)} is required.`);
|
|
188
|
+
}
|
|
189
|
+
normalizeHttpUrl(options.hubUrl, '--hub-url');
|
|
190
|
+
normalizeHttpUrl(options.coreUrl, '--core-url');
|
|
191
|
+
}
|
|
192
|
+
function parseInstallPackages(value) {
|
|
193
|
+
if (!value.trim())
|
|
194
|
+
return [];
|
|
195
|
+
return value
|
|
196
|
+
.split(',')
|
|
197
|
+
.map((item) => item.trim())
|
|
198
|
+
.filter(Boolean);
|
|
199
|
+
}
|
|
200
|
+
function packageInstallList(options) {
|
|
201
|
+
if (options.installPackages.length > 0)
|
|
202
|
+
return [...options.installPackages];
|
|
203
|
+
return [
|
|
204
|
+
`@bbigbang/cli@${options.version}`,
|
|
205
|
+
`@bbigbang/core@${options.version}`,
|
|
206
|
+
`@bbigbang/agent-node@${options.version}`,
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
async function startServices(options, paths) {
|
|
210
|
+
if (options.service === 'none')
|
|
211
|
+
return;
|
|
212
|
+
if (options.service === 'systemd') {
|
|
213
|
+
try {
|
|
214
|
+
installSystemdServices(paths);
|
|
215
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
216
|
+
run('systemctl', ['--user', 'enable', '--now', 'bigbang-core.service', 'bigbang-node.service', 'bigbang-bridge.service']);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
console.warn(`systemd setup failed; falling back to tmux: ${String(error?.message ?? error)}`);
|
|
221
|
+
cleanupSystemdServices();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
startTmuxServices(paths);
|
|
225
|
+
}
|
|
226
|
+
function installSystemdServices(paths) {
|
|
227
|
+
const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
228
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
229
|
+
fs.writeFileSync(path.join(dir, 'bigbang-core.service'), [
|
|
230
|
+
'[Unit]',
|
|
231
|
+
'Description=Bigbang Core',
|
|
232
|
+
'',
|
|
233
|
+
'[Service]',
|
|
234
|
+
`Environment=BIGBANG_HOME=${paths.coreHome}`,
|
|
235
|
+
'ExecStart=bigbang-core',
|
|
236
|
+
'Restart=always',
|
|
237
|
+
'RestartSec=3',
|
|
238
|
+
'',
|
|
239
|
+
'[Install]',
|
|
240
|
+
'WantedBy=default.target',
|
|
241
|
+
'',
|
|
242
|
+
].join('\n'), 'utf8');
|
|
243
|
+
fs.writeFileSync(path.join(dir, 'bigbang-node.service'), [
|
|
244
|
+
'[Unit]',
|
|
245
|
+
'Description=Bigbang Agent Node',
|
|
246
|
+
'After=bigbang-core.service',
|
|
247
|
+
'',
|
|
248
|
+
'[Service]',
|
|
249
|
+
`EnvironmentFile=${paths.nodeEnvPath}`,
|
|
250
|
+
'ExecStart=bigbang-node',
|
|
251
|
+
'Restart=always',
|
|
252
|
+
'RestartSec=3',
|
|
253
|
+
'',
|
|
254
|
+
'[Install]',
|
|
255
|
+
'WantedBy=default.target',
|
|
256
|
+
'',
|
|
257
|
+
].join('\n'), 'utf8');
|
|
258
|
+
fs.writeFileSync(path.join(dir, 'bigbang-bridge.service'), [
|
|
259
|
+
'[Unit]',
|
|
260
|
+
'Description=Bigbang Hub Bridge',
|
|
261
|
+
'After=bigbang-core.service',
|
|
262
|
+
'',
|
|
263
|
+
'[Service]',
|
|
264
|
+
`EnvironmentFile=${paths.bridgeEnvPath}`,
|
|
265
|
+
'ExecStart=bigbang server bridge',
|
|
266
|
+
'Restart=always',
|
|
267
|
+
'RestartSec=3',
|
|
268
|
+
'',
|
|
269
|
+
'[Install]',
|
|
270
|
+
'WantedBy=default.target',
|
|
271
|
+
'',
|
|
272
|
+
].join('\n'), 'utf8');
|
|
273
|
+
}
|
|
274
|
+
function cleanupSystemdServices() {
|
|
275
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'bigbang-core.service', 'bigbang-node.service', 'bigbang-bridge.service'], { stdio: 'ignore' });
|
|
276
|
+
const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
277
|
+
fs.rmSync(path.join(dir, 'bigbang-core.service'), { force: true });
|
|
278
|
+
fs.rmSync(path.join(dir, 'bigbang-node.service'), { force: true });
|
|
279
|
+
fs.rmSync(path.join(dir, 'bigbang-bridge.service'), { force: true });
|
|
280
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
281
|
+
}
|
|
282
|
+
function startTmuxServices(paths) {
|
|
283
|
+
const session = 'bigbang-server';
|
|
284
|
+
spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
|
|
285
|
+
run('tmux', ['new-session', '-d', '-s', session, `BIGBANG_HOME=${shellQuote(paths.coreHome)} bigbang-core`]);
|
|
286
|
+
run('tmux', ['new-window', '-t', session, '-n', 'node', `set -a; . ${shellQuote(paths.nodeEnvPath)}; set +a; bigbang-node`]);
|
|
287
|
+
run('tmux', ['new-window', '-t', session, '-n', 'bridge', `set -a; . ${shellQuote(paths.bridgeEnvPath)}; set +a; bigbang server bridge`]);
|
|
288
|
+
}
|
|
289
|
+
export async function runBridge(options) {
|
|
290
|
+
const sockets = new Map();
|
|
291
|
+
let reconnectDelayMs = 1000;
|
|
292
|
+
let currentToken = readPersistedBridgeToken(options.credentialPath) ?? options.token;
|
|
293
|
+
for (;;) {
|
|
294
|
+
const fatalError = await new Promise((resolve) => {
|
|
295
|
+
let settled = false;
|
|
296
|
+
const finish = (error) => {
|
|
297
|
+
if (settled)
|
|
298
|
+
return;
|
|
299
|
+
settled = true;
|
|
300
|
+
resolve(error);
|
|
301
|
+
};
|
|
302
|
+
const ws = new WebSocket(buildBridgeUrl(options.hubUrl, currentToken));
|
|
303
|
+
const send = (message) => {
|
|
304
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
305
|
+
ws.send(JSON.stringify(message));
|
|
306
|
+
};
|
|
307
|
+
ws.once('open', () => {
|
|
308
|
+
reconnectDelayMs = 1000;
|
|
309
|
+
send({
|
|
310
|
+
type: 'hello',
|
|
311
|
+
token: currentToken,
|
|
312
|
+
serverId: options.serverId,
|
|
313
|
+
serverSlug: options.serverSlug,
|
|
314
|
+
serverName: options.serverName,
|
|
315
|
+
version: options.version,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
ws.on('message', (raw) => {
|
|
319
|
+
const message = parseHubMessage(raw);
|
|
320
|
+
if (!message)
|
|
321
|
+
return;
|
|
322
|
+
if (message.type === 'hello_ack') {
|
|
323
|
+
if (message.bridgeToken) {
|
|
324
|
+
currentToken = message.bridgeToken;
|
|
325
|
+
writePersistedBridgeToken(options.credentialPath, message.bridgeToken);
|
|
326
|
+
}
|
|
327
|
+
console.log(`[bridge] connected ${message.serverSlug}`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (message.type === 'hello_error') {
|
|
331
|
+
console.error(`[bridge] rejected: ${message.error}`);
|
|
332
|
+
finish(new Error(message.error));
|
|
333
|
+
ws.close();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (message.type === 'http_request') {
|
|
337
|
+
void handleBridgeHttpRequest(options, message, send);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (message.type === 'ws_open') {
|
|
341
|
+
openBridgeWebSocket(options, message, send, sockets);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (message.type === 'ws_data') {
|
|
345
|
+
sockets.get(message.requestId)?.write(Buffer.from(message.bodyBase64, 'base64'));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (message.type === 'ws_close') {
|
|
349
|
+
const socket = sockets.get(message.requestId);
|
|
350
|
+
sockets.delete(message.requestId);
|
|
351
|
+
socket?.destroy();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (message.type === 'ping')
|
|
355
|
+
send({ type: 'pong' });
|
|
356
|
+
});
|
|
357
|
+
ws.once('close', () => {
|
|
358
|
+
for (const socket of sockets.values())
|
|
359
|
+
socket.destroy();
|
|
360
|
+
sockets.clear();
|
|
361
|
+
finish(null);
|
|
362
|
+
});
|
|
363
|
+
ws.once('error', (error) => {
|
|
364
|
+
console.error(`[bridge] connection error: ${String(error.message ?? error)}`);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
if (fatalError)
|
|
368
|
+
throw fatalError;
|
|
369
|
+
await sleep(reconnectDelayMs);
|
|
370
|
+
reconnectDelayMs = Math.min(reconnectDelayMs * 2, 30_000);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function handleBridgeHttpRequest(options, message, send) {
|
|
374
|
+
try {
|
|
375
|
+
const response = await fetch(new URL(message.path, `${stripTrailingSlash(options.coreUrl)}/`), {
|
|
376
|
+
method: message.method,
|
|
377
|
+
headers: message.headers,
|
|
378
|
+
body: message.bodyBase64 ? Buffer.from(message.bodyBase64, 'base64') : undefined,
|
|
379
|
+
});
|
|
380
|
+
const body = await readResponseBodyLimited(response, MAX_BRIDGE_HTTP_BODY_BYTES);
|
|
381
|
+
send({
|
|
382
|
+
type: 'http_response',
|
|
383
|
+
requestId: message.requestId,
|
|
384
|
+
statusCode: response.status,
|
|
385
|
+
headers: headersToRecord(response.headers),
|
|
386
|
+
bodyBase64: body.length > 0 ? body.toString('base64') : undefined,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
if (error instanceof BridgeBodyTooLargeError) {
|
|
391
|
+
send({
|
|
392
|
+
type: 'http_response',
|
|
393
|
+
requestId: message.requestId,
|
|
394
|
+
statusCode: 413,
|
|
395
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
396
|
+
bodyBase64: Buffer.from(JSON.stringify({ error: error.message })).toString('base64'),
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
send({ type: 'http_error', requestId: message.requestId, error: String(error.message ?? error) });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
class BridgeBodyTooLargeError extends Error {
|
|
404
|
+
}
|
|
405
|
+
async function readResponseBodyLimited(response, maxBytes) {
|
|
406
|
+
const contentLength = response.headers.get('content-length');
|
|
407
|
+
if (contentLength && Number(contentLength) > maxBytes) {
|
|
408
|
+
throw new BridgeBodyTooLargeError('Bridge HTTP response body is too large.');
|
|
409
|
+
}
|
|
410
|
+
if (!response.body)
|
|
411
|
+
return Buffer.alloc(0);
|
|
412
|
+
const reader = response.body.getReader();
|
|
413
|
+
const chunks = [];
|
|
414
|
+
let totalBytes = 0;
|
|
415
|
+
for (;;) {
|
|
416
|
+
const { done, value } = await reader.read();
|
|
417
|
+
if (done)
|
|
418
|
+
break;
|
|
419
|
+
const buffer = Buffer.from(value);
|
|
420
|
+
totalBytes += buffer.length;
|
|
421
|
+
if (totalBytes > maxBytes) {
|
|
422
|
+
await reader.cancel();
|
|
423
|
+
throw new BridgeBodyTooLargeError('Bridge HTTP response body is too large.');
|
|
424
|
+
}
|
|
425
|
+
chunks.push(buffer);
|
|
426
|
+
}
|
|
427
|
+
return Buffer.concat(chunks);
|
|
428
|
+
}
|
|
429
|
+
function openBridgeWebSocket(options, message, send, sockets) {
|
|
430
|
+
const coreUrl = new URL(options.coreUrl);
|
|
431
|
+
const socket = net.connect(Number(coreUrl.port || 80), coreUrl.hostname);
|
|
432
|
+
sockets.set(message.requestId, socket);
|
|
433
|
+
let handshake = Buffer.alloc(0);
|
|
434
|
+
let opened = false;
|
|
435
|
+
socket.once('connect', () => {
|
|
436
|
+
socket.write(buildLocalUpgradeRequest(message.path, coreUrl.host, message.headers));
|
|
437
|
+
});
|
|
438
|
+
socket.on('data', (chunk) => {
|
|
439
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
440
|
+
if (!opened) {
|
|
441
|
+
handshake = Buffer.concat([handshake, buffer]);
|
|
442
|
+
const headerEnd = handshake.indexOf('\r\n\r\n');
|
|
443
|
+
if (headerEnd < 0)
|
|
444
|
+
return;
|
|
445
|
+
const headerText = handshake.subarray(0, headerEnd).toString('latin1');
|
|
446
|
+
const statusCode = parseStatusCode(headerText) ?? 502;
|
|
447
|
+
opened = statusCode === 101;
|
|
448
|
+
send({ type: 'ws_opened', requestId: message.requestId, statusCode, headers: parseHeaders(headerText) });
|
|
449
|
+
const rest = handshake.subarray(headerEnd + 4);
|
|
450
|
+
if (rest.length > 0)
|
|
451
|
+
send({ type: 'ws_data', requestId: message.requestId, bodyBase64: rest.toString('base64') });
|
|
452
|
+
if (!opened) {
|
|
453
|
+
sockets.delete(message.requestId);
|
|
454
|
+
socket.destroy();
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
send({ type: 'ws_data', requestId: message.requestId, bodyBase64: buffer.toString('base64') });
|
|
459
|
+
});
|
|
460
|
+
socket.once('close', () => {
|
|
461
|
+
sockets.delete(message.requestId);
|
|
462
|
+
send({ type: 'ws_close', requestId: message.requestId, reason: 'local core closed' });
|
|
463
|
+
});
|
|
464
|
+
socket.once('error', (error) => {
|
|
465
|
+
sockets.delete(message.requestId);
|
|
466
|
+
send({ type: 'ws_error', requestId: message.requestId, error: String(error.message ?? error) });
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
function parseHubMessage(raw) {
|
|
470
|
+
try {
|
|
471
|
+
const text = Array.isArray(raw) ? Buffer.concat(raw).toString('utf8') : Buffer.from(raw).toString('utf8');
|
|
472
|
+
const parsed = JSON.parse(text);
|
|
473
|
+
return parsed && typeof parsed === 'object' && typeof parsed.type === 'string' ? parsed : null;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function buildLocalUpgradeRequest(pathname, host, headers) {
|
|
480
|
+
const lines = [`GET ${pathname} HTTP/1.1`];
|
|
481
|
+
const next = { ...headers, host, connection: 'Upgrade', upgrade: 'websocket' };
|
|
482
|
+
for (const [name, value] of Object.entries(next)) {
|
|
483
|
+
if (Array.isArray(value)) {
|
|
484
|
+
for (const item of value)
|
|
485
|
+
lines.push(`${name}: ${item}`);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
lines.push(`${name}: ${value}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
lines.push('', '');
|
|
492
|
+
return lines.join('\r\n');
|
|
493
|
+
}
|
|
494
|
+
function parseStatusCode(headerText) {
|
|
495
|
+
const match = /^HTTP\/\d(?:\.\d)?\s+(\d{3})(?:\s|$)/u.exec(headerText);
|
|
496
|
+
return match ? Number(match[1]) : null;
|
|
497
|
+
}
|
|
498
|
+
function parseHeaders(headerText) {
|
|
499
|
+
const headers = {};
|
|
500
|
+
for (const line of headerText.split('\r\n').slice(1)) {
|
|
501
|
+
const separator = line.indexOf(':');
|
|
502
|
+
if (separator < 0)
|
|
503
|
+
continue;
|
|
504
|
+
const name = line.slice(0, separator).trim();
|
|
505
|
+
const value = line.slice(separator + 1).trim();
|
|
506
|
+
const existing = headers[name];
|
|
507
|
+
if (Array.isArray(existing))
|
|
508
|
+
existing.push(value);
|
|
509
|
+
else if (typeof existing === 'string')
|
|
510
|
+
headers[name] = [existing, value];
|
|
511
|
+
else
|
|
512
|
+
headers[name] = value;
|
|
513
|
+
}
|
|
514
|
+
return headers;
|
|
515
|
+
}
|
|
516
|
+
function headersToRecord(headers) {
|
|
517
|
+
const record = {};
|
|
518
|
+
headers.forEach((value, key) => {
|
|
519
|
+
record[key] = value;
|
|
520
|
+
});
|
|
521
|
+
return record;
|
|
522
|
+
}
|
|
523
|
+
function buildBridgeUrl(hubUrl, token) {
|
|
524
|
+
const url = new URL('api/hub/server-bridge/ws', `${stripTrailingSlash(hubUrl)}/`);
|
|
525
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
526
|
+
url.searchParams.set('token', token);
|
|
527
|
+
return url.toString();
|
|
528
|
+
}
|
|
529
|
+
function normalizeHttpUrl(value, name) {
|
|
530
|
+
try {
|
|
531
|
+
const parsed = new URL(value);
|
|
532
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
533
|
+
throw new Error('bad protocol');
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
throw new Error(`${name} must be an http(s) URL.`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function run(command, args) {
|
|
540
|
+
const result = spawnSync(command, args, { stdio: 'inherit' });
|
|
541
|
+
if (result.status !== 0)
|
|
542
|
+
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status ?? 'unknown'}`);
|
|
543
|
+
}
|
|
544
|
+
function stripTrailingSlash(value) {
|
|
545
|
+
return value.replace(/\/+$/u, '');
|
|
546
|
+
}
|
|
547
|
+
function sleep(ms) {
|
|
548
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
549
|
+
}
|
|
550
|
+
function cryptoRandomToken() {
|
|
551
|
+
return `internal-${randomUUID()}`;
|
|
552
|
+
}
|
|
553
|
+
function readPersistedBridgeToken(credentialPath) {
|
|
554
|
+
try {
|
|
555
|
+
const parsed = JSON.parse(fs.readFileSync(credentialPath, 'utf8'));
|
|
556
|
+
return typeof parsed.bridgeToken === 'string' && parsed.bridgeToken.trim() ? parsed.bridgeToken.trim() : null;
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function writePersistedBridgeToken(credentialPath, bridgeToken) {
|
|
563
|
+
fs.mkdirSync(path.dirname(credentialPath), { recursive: true });
|
|
564
|
+
const tempPath = `${credentialPath}.${process.pid}.${Date.now()}.tmp`;
|
|
565
|
+
fs.writeFileSync(tempPath, `${JSON.stringify({ bridgeToken }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
566
|
+
fs.renameSync(tempPath, credentialPath);
|
|
567
|
+
}
|
|
568
|
+
function envLine(key, value) {
|
|
569
|
+
return `${key}=${shellQuote(value)}`;
|
|
570
|
+
}
|
|
571
|
+
function shellQuote(value) {
|
|
572
|
+
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
573
|
+
}
|
|
574
|
+
function printUsage() {
|
|
575
|
+
console.log(`Usage:
|
|
576
|
+
bigbang server join --hub-url <url> --token <token> --server-id <id> --server-slug <slug> --server-name <name> --version <semver> [options]
|
|
577
|
+
bigbang server bridge --hub-url <url> --token <token> --server-id <id> --server-slug <slug> --server-name <name> --version <semver> [--core-url <url>]
|
|
578
|
+
|
|
579
|
+
Options:
|
|
580
|
+
--port <n> Local core port. Defaults to 3100.
|
|
581
|
+
--service <kind> systemd, tmux, or none. Defaults to systemd.
|
|
582
|
+
--install-packages <csv> Comma-separated package specs for core/node/CLI installs.
|
|
583
|
+
--dry-run Print planned actions without writing files or starting services.
|
|
584
|
+
`);
|
|
585
|
+
}
|
|
586
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
587
|
+
main().catch((error) => {
|
|
588
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
589
|
+
process.exit(1);
|
|
590
|
+
});
|
|
591
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bbigbang/server-ops",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Bigbang server join and bridge operations",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"!dist/**/__tests__/**",
|
|
11
|
+
"!dist/**/*.test.*",
|
|
12
|
+
"!dist/**/*.map",
|
|
13
|
+
"package.json"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"ws": "^8.18.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^25.3.3",
|
|
23
|
+
"@types/ws": "^8.18.1",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^3.2.1"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
}
|
|
32
|
+
}
|