ftown-bridge 0.19.19 → 0.19.21
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.js +228 -3
- package/dist/index.js.map +1 -1
- package/dist/solo/contract.d.ts +104 -0
- package/dist/solo/contract.js +248 -0
- package/dist/solo/contract.js.map +1 -0
- package/dist/solo/hub-manager.d.ts +85 -0
- package/dist/solo/hub-manager.js +381 -0
- package/dist/solo/hub-manager.js.map +1 -0
- package/dist/solo/panel-manager.d.ts +129 -0
- package/dist/solo/panel-manager.js +715 -0
- package/dist/solo/panel-manager.js.map +1 -0
- package/dist/solo/solo-auth.d.ts +25 -0
- package/dist/solo/solo-auth.js +83 -0
- package/dist/solo/solo-auth.js.map +1 -0
- package/dist/solo/solo-server.d.ts +105 -0
- package/dist/solo/solo-server.js +399 -0
- package/dist/solo/solo-server.js.map +1 -0
- package/dist/solo/ws-proxy.d.ts +55 -0
- package/dist/solo/ws-proxy.js +228 -0
- package/dist/solo/ws-proxy.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ftown Solo — panel-manager.
|
|
3
|
+
*
|
|
4
|
+
* Owns the managed Next.js STANDALONE child ("the panel"):
|
|
5
|
+
* - bundle URL templating from PANEL_BUNDLE_URL_TEMPLATE
|
|
6
|
+
* - bundle fetch + sha256 sidecar verification (S6, first-party trust root)
|
|
7
|
+
* - extraction hardened per S4 (zip-slip, entry-type allowlist) and S17
|
|
8
|
+
* (per-entry / total uncompressed / entry-count caps — decompression bombs)
|
|
9
|
+
* - spawn on 127.0.0.1:<port>, pinned via HOSTNAME/PORT env (private binding),
|
|
10
|
+
* argv carries ONLY [interpreter, server.js] — no secrets ever (S15)
|
|
11
|
+
* - lifecycle L2: pidfile under <dataDir>/solo/panel.pid with stale-orphan
|
|
12
|
+
* reap on boot; SIGTERM → 3s → SIGKILL shutdown
|
|
13
|
+
* - pinned health probe: HEAD http://127.0.0.1:<port>/ , status <500 = up,
|
|
14
|
+
* 1s probe timeout, 45s give-up (cold-start budget)
|
|
15
|
+
*
|
|
16
|
+
* EXTERNAL TOOLING CHOICE (documented): the system `tar` binary is used for
|
|
17
|
+
* extraction (`tar -xzf --no-same-owner`) and for the entry-name pre-listing
|
|
18
|
+
* (`tar -tzf`). Structural enforcement (S4 type allowlist, S17 byte caps) is
|
|
19
|
+
* performed by walking the ustar headers of the gunzipped stream with node
|
|
20
|
+
* builtins only: `-t` output is prose whose layout differs between bsdtar and
|
|
21
|
+
* GNU tar (and is ambiguous for filenames containing whitespace), whereas raw
|
|
22
|
+
* ustar headers are exact, portable, and let us abort MID-STREAM on cap
|
|
23
|
+
* breach instead of after extraction. No third-party dependencies.
|
|
24
|
+
*/
|
|
25
|
+
import { execFile, spawn as nodeSpawn } from 'node:child_process';
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import fs from 'node:fs/promises';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
30
|
+
import { createReadStream } from 'node:fs';
|
|
31
|
+
import { createGunzip } from 'node:zlib';
|
|
32
|
+
import { promisify } from 'node:util';
|
|
33
|
+
import { PANEL_BUNDLE_URL_TEMPLATE } from './contract.js';
|
|
34
|
+
const execFileP = promisify(execFile);
|
|
35
|
+
// ---------- Errors ----------
|
|
36
|
+
/** Any panel bundle acquisition/installation failure. */
|
|
37
|
+
export class PanelBundleError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'PanelBundleError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** sha256 sidecar verification failure (S6) — install aborted, nothing ran. */
|
|
44
|
+
export class ChecksumError extends PanelBundleError {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'ChecksumError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Panel child failed to spawn or never became healthy. */
|
|
51
|
+
export class PanelStartError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'PanelStartError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ---------- Limits (S17) ----------
|
|
58
|
+
/** Per-entry uncompressed size cap (decompression-bomb defense). */
|
|
59
|
+
export const MAX_ENTRY_BYTES = 200 * 1024 * 1024;
|
|
60
|
+
/** Total uncompressed size cap across all entries. */
|
|
61
|
+
export const MAX_TOTAL_BYTES = 500 * 1024 * 1024;
|
|
62
|
+
/** Maximum number of archive entries. */
|
|
63
|
+
export const MAX_ENTRIES = 20_000;
|
|
64
|
+
/** Pinned panel health-probe parameters (contract: probe table). */
|
|
65
|
+
export const PANEL_HEALTH_PROBE_TIMEOUT_MS = 1_000;
|
|
66
|
+
export const PANEL_HEALTH_MAX_WAIT_MS = 45_000;
|
|
67
|
+
/** L2 shutdown: SIGTERM → 3s → SIGKILL. */
|
|
68
|
+
export const STOP_GRACE_MS = 3_000;
|
|
69
|
+
/** Inter-poll delay while waiting for the panel to become healthy. */
|
|
70
|
+
const HEALTH_POLL_INTERVAL_MS = 250;
|
|
71
|
+
/** Poll cadence when waiting for a signalled pid to die. */
|
|
72
|
+
const PID_DEATH_POLL_MS = 50;
|
|
73
|
+
/** Bytes of child stderr retained for sanitized error reporting (S16-safe). */
|
|
74
|
+
const STDERR_TAIL_BYTES = 4_096;
|
|
75
|
+
// ---------- URL templating ----------
|
|
76
|
+
/** Substitute every `<version>` placeholder in the frozen template. */
|
|
77
|
+
export function panelBundleUrl(version) {
|
|
78
|
+
return PANEL_BUNDLE_URL_TEMPLATE.replaceAll('<version>', version);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Normalize a user- or config-supplied panel version into the bare semver
|
|
82
|
+
* expected by {@link panelBundleUrl} (the template already supplies the
|
|
83
|
+
* `v` prefix for the release-tag segment). Trims surrounding whitespace and
|
|
84
|
+
* strips a single leading `v`/`V`, so `v0.19.20`, `V0.19.20`, and `0.19.20`
|
|
85
|
+
* all resolve to the same bundle URL.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizePanelVersion(raw) {
|
|
88
|
+
const trimmed = raw.trim();
|
|
89
|
+
return /^[vV]/.test(trimmed) ? trimmed.slice(1) : trimmed;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Ensure the standalone bundle for `version` is extracted under
|
|
93
|
+
* `<dataDir>/solo/panel/<version>/` and return that directory.
|
|
94
|
+
*
|
|
95
|
+
* Flow: cached-marker short-circuit → download tar.gz → download `.sha256`
|
|
96
|
+
* sidecar → lowercase-hex comparison (mismatch ⇒ cleanup + ChecksumError) →
|
|
97
|
+
* pre-scan (`tar -tzf` names + structural ustar walk enforcing S4/S17) →
|
|
98
|
+
* `tar -xzf --no-same-owner` → write `.ok` marker (0600).
|
|
99
|
+
*/
|
|
100
|
+
export async function ensurePanelBundle(opts) {
|
|
101
|
+
assertSafeVersion(opts.version);
|
|
102
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
103
|
+
const versionDir = path.join(opts.dataDir, 'solo', 'panel', opts.version);
|
|
104
|
+
const marker = path.join(versionDir, '.ok');
|
|
105
|
+
if (await isFile(marker))
|
|
106
|
+
return versionDir;
|
|
107
|
+
try {
|
|
108
|
+
// No marker ⇒ prior run was interrupted or tampered: rebuild from scratch.
|
|
109
|
+
await fs.rm(versionDir, { recursive: true, force: true });
|
|
110
|
+
await fs.mkdir(versionDir, { recursive: true });
|
|
111
|
+
await assertContained(opts.dataDir, versionDir, opts.version);
|
|
112
|
+
const url = panelBundleUrl(opts.version);
|
|
113
|
+
const bundleRes = await doFetch(url);
|
|
114
|
+
if (!bundleRes.ok) {
|
|
115
|
+
throw new PanelBundleError(`panel bundle download failed: HTTP ${bundleRes.status} for ${url}`);
|
|
116
|
+
}
|
|
117
|
+
const bytes = Buffer.from(await bundleRes.arrayBuffer());
|
|
118
|
+
const sidecarRes = await doFetch(`${url}.sha256`);
|
|
119
|
+
if (!sidecarRes.ok) {
|
|
120
|
+
throw new ChecksumError(`panel bundle checksum sidecar download failed: HTTP ${sidecarRes.status}`);
|
|
121
|
+
}
|
|
122
|
+
const expected = (await sidecarRes.text()).trim().toLowerCase();
|
|
123
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) {
|
|
124
|
+
throw new ChecksumError('panel bundle checksum sidecar is malformed (expected 64 hex chars)');
|
|
125
|
+
}
|
|
126
|
+
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
127
|
+
if (actual !== expected) {
|
|
128
|
+
throw new ChecksumError(`panel bundle checksum mismatch: expected ${expected}, got ${actual}`);
|
|
129
|
+
}
|
|
130
|
+
const archivePath = path.join(versionDir, 'bundle.tar.gz');
|
|
131
|
+
await fs.writeFile(archivePath, bytes, { mode: 0o600 });
|
|
132
|
+
// Pre-scan pass 1: structural ustar walk (entry-type allowlist + S17 caps
|
|
133
|
+
// + name safety), aborting mid-stream on any breach. Runs before the
|
|
134
|
+
// system-tar listing because hostile archives can make `tar -t` itself
|
|
135
|
+
// choke on truncation; the structural walk gives precise diagnostics.
|
|
136
|
+
await scanTarStructure(archivePath);
|
|
137
|
+
// Pre-scan pass 2: independent system-tar entry-name cross-check
|
|
138
|
+
// (absolute paths, ".." segments, entry count).
|
|
139
|
+
const names = await listTarEntryNames(archivePath);
|
|
140
|
+
if (names.length > MAX_ENTRIES) {
|
|
141
|
+
throw new PanelBundleError(`panel bundle rejects archive: ${names.length} entries exceeds limit ${MAX_ENTRIES}`);
|
|
142
|
+
}
|
|
143
|
+
for (const name of names)
|
|
144
|
+
assertSafeEntryName(name);
|
|
145
|
+
await extractArchive(archivePath, versionDir);
|
|
146
|
+
await fs.rm(archivePath, { force: true });
|
|
147
|
+
await fs.writeFile(marker, `${opts.version}\n`, { mode: 0o600 });
|
|
148
|
+
return versionDir;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// Failed installs leave nothing behind.
|
|
152
|
+
await fs.rm(versionDir, { recursive: true, force: true }).catch(() => { });
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function assertSafeVersion(version) {
|
|
157
|
+
if (!/^[\w.-]+$/.test(version) || version.includes('..')) {
|
|
158
|
+
throw new PanelBundleError(`refusing unsafe panel version identifier: ${version}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function assertContained(dataDir, versionDir, version) {
|
|
162
|
+
const realData = await fs.realpath(dataDir);
|
|
163
|
+
const expected = path.join(realData, 'solo', 'panel', version);
|
|
164
|
+
const realDest = await fs.realpath(versionDir);
|
|
165
|
+
if (realDest !== expected) {
|
|
166
|
+
throw new PanelBundleError(`extraction target ${realDest} resolved outside its dataDir subdirectory (${expected})`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function isFile(p) {
|
|
170
|
+
try {
|
|
171
|
+
const st = await fs.stat(p);
|
|
172
|
+
return st.isFile();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ---------- Archive scanning ----------
|
|
179
|
+
/** Pass 1: entry NAMES via the system tar binary. */
|
|
180
|
+
async function listTarEntryNames(archivePath) {
|
|
181
|
+
let stdout;
|
|
182
|
+
try {
|
|
183
|
+
const res = await execFileP('tar', ['-tzf', archivePath], {
|
|
184
|
+
timeout: 120_000,
|
|
185
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
186
|
+
});
|
|
187
|
+
stdout = res.stdout;
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
191
|
+
throw new PanelBundleError(`panel bundle archive unreadable by tar: ${detail}`);
|
|
192
|
+
}
|
|
193
|
+
return stdout
|
|
194
|
+
.split('\n')
|
|
195
|
+
.map((line) => line.replace(/\r$/, ''))
|
|
196
|
+
.filter((line) => line.length > 0);
|
|
197
|
+
}
|
|
198
|
+
/** Rejects absolute paths, windows drives, and any ".." segment (S4). */
|
|
199
|
+
function assertSafeEntryName(name) {
|
|
200
|
+
const normalizedName = name.replace(/\/+$/, '');
|
|
201
|
+
if (normalizedName.startsWith('/') || /^[A-Za-z]:[\\/]/.test(normalizedName)) {
|
|
202
|
+
throw new PanelBundleError(`panel bundle rejects archive entry with absolute path: ${name}`);
|
|
203
|
+
}
|
|
204
|
+
if (normalizedName.split('/').includes('..')) {
|
|
205
|
+
throw new PanelBundleError(`panel bundle rejects archive entry with '..' segment: ${name}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Pass 2: stream the gunzipped archive and walk raw 512-byte ustar headers.
|
|
210
|
+
* Enforces the S4 type allowlist (regular files and directories only —
|
|
211
|
+
* symlinks/hardlinks/devices/fifos rejected), the S17 caps, and name safety,
|
|
212
|
+
* aborting mid-stream BEFORE extraction. Understands pax ('x'/'g') and GNU
|
|
213
|
+
* ('L'/'K') metadata records so long paths resolve correctly.
|
|
214
|
+
*/
|
|
215
|
+
async function scanTarStructure(archivePath) {
|
|
216
|
+
const source = createReadStream(archivePath);
|
|
217
|
+
const stream = source.pipe(createGunzip());
|
|
218
|
+
let buf = Buffer.alloc(0);
|
|
219
|
+
let sawZeroBlock = false;
|
|
220
|
+
let finished = false;
|
|
221
|
+
let totalBytes = 0;
|
|
222
|
+
let entryCount = 0;
|
|
223
|
+
// Pending metadata applying to the NEXT real entry.
|
|
224
|
+
let pendingPath;
|
|
225
|
+
let pendingSize;
|
|
226
|
+
// Accumulator for metadata record bodies (pax key=value, GNU longname).
|
|
227
|
+
let metaBody = null;
|
|
228
|
+
const fail = (err) => {
|
|
229
|
+
finished = true;
|
|
230
|
+
source.destroy();
|
|
231
|
+
stream.destroy();
|
|
232
|
+
rejectRun(err);
|
|
233
|
+
};
|
|
234
|
+
let rejectRun;
|
|
235
|
+
let resolveRun;
|
|
236
|
+
const done = new Promise((resolve, reject) => {
|
|
237
|
+
resolveRun = resolve;
|
|
238
|
+
rejectRun = reject;
|
|
239
|
+
});
|
|
240
|
+
const handleBlock = (block) => {
|
|
241
|
+
if (isZeroBlock(block)) {
|
|
242
|
+
sawZeroBlock = true;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (sawZeroBlock) {
|
|
246
|
+
throw new Error('archive contains data after end-of-archive blocks');
|
|
247
|
+
}
|
|
248
|
+
const rawName = readTarString(block, 0, 100);
|
|
249
|
+
const prefix = readTarString(block, 345, 155);
|
|
250
|
+
const declaredSize = readTarSize(block, 124, 12);
|
|
251
|
+
const typeflag = String.fromCharCode(block[156]);
|
|
252
|
+
// Metadata records describe the NEXT entry; their bodies are consumed
|
|
253
|
+
// separately below and never count toward caps or the type allowlist.
|
|
254
|
+
if (typeflag === 'x' || typeflag === 'g' || typeflag === 'L' || typeflag === 'K') {
|
|
255
|
+
metaKind = typeflag;
|
|
256
|
+
metaBody = { chunks: [], remaining: paddedSize(declaredSize) };
|
|
257
|
+
buf = Buffer.alloc(0);
|
|
258
|
+
enterBody(metaBody.remaining);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const name = pendingPath ?? (prefix.length > 0 ? `${prefix}/${rawName}` : rawName);
|
|
262
|
+
const size = pendingSize ?? declaredSize;
|
|
263
|
+
pendingPath = undefined;
|
|
264
|
+
pendingSize = undefined;
|
|
265
|
+
// S17: explicit entry-type allowlist — regular files and directories only.
|
|
266
|
+
const isRegular = typeflag === '0' || typeflag === '\0' || typeflag === '7';
|
|
267
|
+
const isDirectory = typeflag === '5';
|
|
268
|
+
if (!isRegular && !isDirectory) {
|
|
269
|
+
throw new Error(`archive entry '${name}' has disallowed tar type '${describeType(typeflag)}' (only regular files and directories are permitted)`);
|
|
270
|
+
}
|
|
271
|
+
assertSafeEntryName(name);
|
|
272
|
+
entryCount += 1;
|
|
273
|
+
if (entryCount > MAX_ENTRIES) {
|
|
274
|
+
throw new Error(`archive exceeds maximum entry count (${MAX_ENTRIES})`);
|
|
275
|
+
}
|
|
276
|
+
if (size > MAX_ENTRY_BYTES) {
|
|
277
|
+
throw new Error(`archive entry '${name}' declares ${size} bytes, exceeding per-entry cap ${MAX_ENTRY_BYTES}`);
|
|
278
|
+
}
|
|
279
|
+
totalBytes += size;
|
|
280
|
+
if (totalBytes > MAX_TOTAL_BYTES) {
|
|
281
|
+
throw new Error(`archive exceeds total uncompressed cap ${MAX_TOTAL_BYTES} at entry '${name}'`);
|
|
282
|
+
}
|
|
283
|
+
buf = Buffer.alloc(0);
|
|
284
|
+
enterBody(paddedSize(size));
|
|
285
|
+
};
|
|
286
|
+
// Body consumption: metadata bodies accumulate; file/dir bodies are counted
|
|
287
|
+
// (already accounted above) and discarded without materializing.
|
|
288
|
+
const enterBody = (padded) => {
|
|
289
|
+
bodyRemaining = padded;
|
|
290
|
+
inBody = true;
|
|
291
|
+
};
|
|
292
|
+
let inBody = false;
|
|
293
|
+
let bodyRemaining = 0;
|
|
294
|
+
const consumeChunk = (chunk) => {
|
|
295
|
+
let offset = 0;
|
|
296
|
+
while (offset < chunk.length) {
|
|
297
|
+
if (inBody) {
|
|
298
|
+
if (metaBody !== null) {
|
|
299
|
+
const take = Math.min(chunk.length - offset, bodyRemaining);
|
|
300
|
+
metaBody.chunks.push(chunk.subarray(offset, offset + take));
|
|
301
|
+
offset += take;
|
|
302
|
+
bodyRemaining -= take;
|
|
303
|
+
if (bodyRemaining === 0) {
|
|
304
|
+
const body = Buffer.concat(metaBody.chunks);
|
|
305
|
+
metaBody = null;
|
|
306
|
+
inBody = false;
|
|
307
|
+
applyMetaBody(body);
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const take = Math.min(chunk.length - offset, bodyRemaining);
|
|
312
|
+
offset += take;
|
|
313
|
+
bodyRemaining -= take;
|
|
314
|
+
if (bodyRemaining === 0)
|
|
315
|
+
inBody = false;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (finished)
|
|
319
|
+
return;
|
|
320
|
+
const available = chunk.length - offset;
|
|
321
|
+
if (buf.length === 0 && available >= 512) {
|
|
322
|
+
handleBlock(chunk.subarray(offset, offset + 512));
|
|
323
|
+
offset += 512;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const need = 512 - buf.length;
|
|
327
|
+
const take = Math.min(need, available);
|
|
328
|
+
buf = Buffer.concat([buf, chunk.subarray(offset, offset + take)]);
|
|
329
|
+
offset += take;
|
|
330
|
+
if (buf.length === 512) {
|
|
331
|
+
const block = buf;
|
|
332
|
+
buf = Buffer.alloc(0);
|
|
333
|
+
handleBlock(block);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
let metaKind;
|
|
338
|
+
const applyMetaBody = (body) => {
|
|
339
|
+
// GNU longname/longlink bodies are raw text; pax bodies are
|
|
340
|
+
// "<len> key=value\n" records.
|
|
341
|
+
if (metaKind === 'L' || metaKind === 'K') {
|
|
342
|
+
const text = readCString(body);
|
|
343
|
+
if (metaKind === 'L')
|
|
344
|
+
pendingPath = text;
|
|
345
|
+
// 'K' (long link target) is irrelevant: link entries are rejected.
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
const overrides = parsePaxRecords(body);
|
|
349
|
+
if (overrides.path !== undefined)
|
|
350
|
+
pendingPath = overrides.path;
|
|
351
|
+
if (overrides.size !== undefined)
|
|
352
|
+
pendingSize = overrides.size;
|
|
353
|
+
}
|
|
354
|
+
metaKind = undefined;
|
|
355
|
+
};
|
|
356
|
+
stream.on('data', (chunk) => {
|
|
357
|
+
try {
|
|
358
|
+
consumeChunk(chunk);
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
362
|
+
fail(new PanelBundleError(`panel bundle archive rejected: ${detail}`));
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
stream.on('error', (err) => fail(new PanelBundleError(`panel bundle archive scan failed: ${err.message}`)));
|
|
366
|
+
stream.on('end', () => {
|
|
367
|
+
if (finished)
|
|
368
|
+
return;
|
|
369
|
+
if (inBody || buf.length > 0) {
|
|
370
|
+
fail(new PanelBundleError('panel bundle archive is truncated'));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
finished = true;
|
|
374
|
+
resolveRun();
|
|
375
|
+
});
|
|
376
|
+
return done;
|
|
377
|
+
}
|
|
378
|
+
function paddedSize(size) {
|
|
379
|
+
return Math.ceil(size / 512) * 512;
|
|
380
|
+
}
|
|
381
|
+
function isZeroBlock(block) {
|
|
382
|
+
for (let i = 0; i < 512; i++) {
|
|
383
|
+
if (block[i] !== 0)
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
function readTarString(block, offset, length) {
|
|
389
|
+
let end = offset;
|
|
390
|
+
const limit = offset + length;
|
|
391
|
+
while (end < limit && block[end] !== 0)
|
|
392
|
+
end++;
|
|
393
|
+
return block.subarray(offset, end).toString('utf8');
|
|
394
|
+
}
|
|
395
|
+
function readCString(body) {
|
|
396
|
+
let end = body.indexOf(0);
|
|
397
|
+
if (end === -1)
|
|
398
|
+
end = body.length;
|
|
399
|
+
return body.subarray(0, end).toString('utf8');
|
|
400
|
+
}
|
|
401
|
+
function readTarSize(block, offset, length) {
|
|
402
|
+
if ((block[offset] & 0x80) !== 0) {
|
|
403
|
+
// GNU base-256 encoding (large sizes).
|
|
404
|
+
let value = 0n;
|
|
405
|
+
for (let i = offset + 1; i < offset + length; i++) {
|
|
406
|
+
value = (value << 8n) | BigInt(block[i]);
|
|
407
|
+
}
|
|
408
|
+
return Number(value);
|
|
409
|
+
}
|
|
410
|
+
const text = readTarString(block, offset, length).trim();
|
|
411
|
+
if (text.length === 0)
|
|
412
|
+
return 0;
|
|
413
|
+
const parsed = Number.parseInt(text, 8);
|
|
414
|
+
if (Number.isNaN(parsed)) {
|
|
415
|
+
throw new Error('archive contains a malformed size field');
|
|
416
|
+
}
|
|
417
|
+
return parsed;
|
|
418
|
+
}
|
|
419
|
+
function describeType(typeflag) {
|
|
420
|
+
switch (typeflag) {
|
|
421
|
+
case '1':
|
|
422
|
+
return 'hardlink';
|
|
423
|
+
case '2':
|
|
424
|
+
return 'symlink';
|
|
425
|
+
case '3':
|
|
426
|
+
return 'char device';
|
|
427
|
+
case '4':
|
|
428
|
+
return 'block device';
|
|
429
|
+
case '6':
|
|
430
|
+
return 'fifo';
|
|
431
|
+
default:
|
|
432
|
+
return `typeflag 0x${typeflag.charCodeAt(0).toString(16)}`;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Parse pax extended-header records (path/size overrides for next entry). */
|
|
436
|
+
function parsePaxRecords(body) {
|
|
437
|
+
const overrides = {};
|
|
438
|
+
let pos = 0;
|
|
439
|
+
while (pos < body.length) {
|
|
440
|
+
let space = pos;
|
|
441
|
+
while (space < body.length && body[space] !== 0x20)
|
|
442
|
+
space++;
|
|
443
|
+
if (space >= body.length)
|
|
444
|
+
break;
|
|
445
|
+
const recordLength = Number.parseInt(body.subarray(pos, space).toString('ascii'), 10);
|
|
446
|
+
if (!Number.isFinite(recordLength) || recordLength <= 0)
|
|
447
|
+
break;
|
|
448
|
+
const record = body.subarray(pos, pos + recordLength);
|
|
449
|
+
let end = record.length;
|
|
450
|
+
while (end > 0 && (record[end - 1] === 0x0a || record[end - 1] === 0))
|
|
451
|
+
end--;
|
|
452
|
+
const pair = record.subarray(space + 1, end).toString('utf8');
|
|
453
|
+
const eq = pair.indexOf('=');
|
|
454
|
+
if (eq > 0) {
|
|
455
|
+
const key = pair.slice(0, eq);
|
|
456
|
+
const value = pair.slice(eq + 1);
|
|
457
|
+
if (key === 'path')
|
|
458
|
+
overrides.path = value;
|
|
459
|
+
if (key === 'size') {
|
|
460
|
+
const parsed = Number.parseInt(value, 10);
|
|
461
|
+
if (Number.isFinite(parsed))
|
|
462
|
+
overrides.size = parsed;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
pos += recordLength;
|
|
466
|
+
}
|
|
467
|
+
return overrides;
|
|
468
|
+
}
|
|
469
|
+
// ---------- Extraction ----------
|
|
470
|
+
/** Extract with the system tar binary; --no-same-owner drops ownership bits. */
|
|
471
|
+
async function extractArchive(archivePath, destDir) {
|
|
472
|
+
try {
|
|
473
|
+
await execFileP('tar', ['-xzf', archivePath, '-C', destDir, '--no-same-owner'], {
|
|
474
|
+
timeout: 300_000,
|
|
475
|
+
maxBuffer: 1024 * 1024,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
480
|
+
throw new PanelBundleError(`panel bundle extraction failed: ${detail}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// ---------- Server-dir discovery ----------
|
|
484
|
+
/**
|
|
485
|
+
* Locate the Next standalone output root: the directory containing server.js.
|
|
486
|
+
* Searches the extract root first, then one level deep (deterministic order).
|
|
487
|
+
*/
|
|
488
|
+
export async function findPanelServerDir(extractDir) {
|
|
489
|
+
if (await isFile(path.join(extractDir, 'server.js')))
|
|
490
|
+
return extractDir;
|
|
491
|
+
let dirents;
|
|
492
|
+
try {
|
|
493
|
+
dirents = await fs.readdir(extractDir, { withFileTypes: true });
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
throw new PanelBundleError(`panel bundle extract dir is missing: ${extractDir}`);
|
|
497
|
+
}
|
|
498
|
+
const sorted = [...dirents].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
499
|
+
for (const dirent of sorted) {
|
|
500
|
+
if (!dirent.isDirectory())
|
|
501
|
+
continue;
|
|
502
|
+
const candidate = path.join(extractDir, dirent.name);
|
|
503
|
+
if (await isFile(path.join(candidate, 'server.js')))
|
|
504
|
+
return candidate;
|
|
505
|
+
}
|
|
506
|
+
throw new PanelBundleError(`panel bundle does not contain a Next standalone root (no server.js found shallow or one level deep) under ${extractDir}`);
|
|
507
|
+
}
|
|
508
|
+
function pidFilePath(dataDir) {
|
|
509
|
+
return path.join(dataDir, 'solo', 'panel.pid');
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Spawn the panel standalone server pinned to 127.0.0.1:<port>.
|
|
513
|
+
*
|
|
514
|
+
* - argv is exactly [process.execPath, <server.js>] — no secrets on the command
|
|
515
|
+
* line (S15). Secrets reach children only through 0600 files, never argv/env.
|
|
516
|
+
* - HOSTNAME=127.0.0.1 pins the standalone server's binding (private port; the
|
|
517
|
+
* front proxies everything).
|
|
518
|
+
* - A stale/orphaned pidfile is detected and reaped before spawn (L2).
|
|
519
|
+
* - Resolves only after the pinned health probe succeeds (HEAD / <500).
|
|
520
|
+
*/
|
|
521
|
+
export async function startPanel(opts) {
|
|
522
|
+
const serverDir = await findPanelServerDir(opts.bundleDir);
|
|
523
|
+
const serverJs = path.join(serverDir, 'server.js');
|
|
524
|
+
const pidfile = pidFilePath(opts.dataDir);
|
|
525
|
+
await reapOrStopByPidFile(pidfile);
|
|
526
|
+
const childEnv = {
|
|
527
|
+
...process.env,
|
|
528
|
+
HOSTNAME: '127.0.0.1',
|
|
529
|
+
PORT: String(opts.port),
|
|
530
|
+
...(opts.env ?? {}),
|
|
531
|
+
};
|
|
532
|
+
const spawnFn = opts.spawnImpl ?? nodeSpawn;
|
|
533
|
+
// S15: argv is exactly interpreter + script. Nothing secret-shaped may pass.
|
|
534
|
+
const proc = spawnFn(process.execPath, [serverJs], {
|
|
535
|
+
cwd: serverDir,
|
|
536
|
+
env: childEnv,
|
|
537
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
538
|
+
});
|
|
539
|
+
const tail = new RollingTail(STDERR_TAIL_BYTES);
|
|
540
|
+
proc.stderr?.on('data', (chunk) => tail.push(chunk));
|
|
541
|
+
// Attach the exit tracker SYNCHRONOUSLY after spawn — a child that dies
|
|
542
|
+
// immediately must never slip past health-wait unnoticed.
|
|
543
|
+
const exitInfo = {
|
|
544
|
+
code: null,
|
|
545
|
+
signal: null,
|
|
546
|
+
exited: false,
|
|
547
|
+
};
|
|
548
|
+
const onExit = (code, signal) => {
|
|
549
|
+
exitInfo.code = code;
|
|
550
|
+
exitInfo.signal = signal;
|
|
551
|
+
exitInfo.exited = true;
|
|
552
|
+
};
|
|
553
|
+
proc.once('exit', onExit);
|
|
554
|
+
const pid = proc.pid;
|
|
555
|
+
if (pid === undefined) {
|
|
556
|
+
proc.off('exit', onExit);
|
|
557
|
+
throw new PanelStartError('panel spawn failed: no pid assigned');
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
await fs.mkdir(path.dirname(pidfile), { recursive: true });
|
|
561
|
+
await fs.writeFile(pidfile, `${JSON.stringify({ pid, port: opts.port, startedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
562
|
+
await waitHealthy(opts.port, exitInfo, tail, opts);
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
await terminateChild(proc);
|
|
566
|
+
await fs.rm(pidfile, { force: true }).catch(() => { });
|
|
567
|
+
throw err;
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
proc.off('exit', onExit);
|
|
571
|
+
}
|
|
572
|
+
return { pid, proc, serverDir };
|
|
573
|
+
}
|
|
574
|
+
/** Pinned probe: HEAD http://127.0.0.1:<port>/ — up iff status < 500. */
|
|
575
|
+
async function waitHealthy(port, exitInfo, tail, opts) {
|
|
576
|
+
const probeTimeoutMs = opts.probeTimeoutMs ?? PANEL_HEALTH_PROBE_TIMEOUT_MS;
|
|
577
|
+
const intervalMs = opts.probeIntervalMs ?? HEALTH_POLL_INTERVAL_MS;
|
|
578
|
+
const maxWaitMs = opts.probeMaxWaitMs ?? PANEL_HEALTH_MAX_WAIT_MS;
|
|
579
|
+
const doFetch = opts.healthFetchImpl ?? fetch;
|
|
580
|
+
const deadline = Date.now() + maxWaitMs;
|
|
581
|
+
const tailSuffix = () => {
|
|
582
|
+
const text = sanitizeStderr(tail.text());
|
|
583
|
+
return text.length > 0 ? `\npanel stderr (sanitized tail): ${text}` : '';
|
|
584
|
+
};
|
|
585
|
+
while (Date.now() < deadline) {
|
|
586
|
+
if (exitInfo.exited) {
|
|
587
|
+
throw new PanelStartError(`panel exited prematurely (code ${exitInfo.code}, signal ${exitInfo.signal})${tailSuffix()}`);
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const res = await doFetch(`http://127.0.0.1:${port}/`, {
|
|
591
|
+
method: 'HEAD',
|
|
592
|
+
redirect: 'manual',
|
|
593
|
+
signal: AbortSignal.timeout(probeTimeoutMs),
|
|
594
|
+
});
|
|
595
|
+
if (res.status < 500)
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// Probe failure ≠ fatal; retry until the deadline.
|
|
600
|
+
}
|
|
601
|
+
await delay(intervalMs);
|
|
602
|
+
}
|
|
603
|
+
throw new PanelStartError(`panel did not become healthy on 127.0.0.1:${port} within ${maxWaitMs}ms${tailSuffix()}`);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Redact anything secret-shaped from stderr excerpts before surfacing them
|
|
607
|
+
* (S16: keys/JWTs/bearer tokens never reach logs).
|
|
608
|
+
*/
|
|
609
|
+
function sanitizeStderr(text) {
|
|
610
|
+
return text
|
|
611
|
+
.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
|
|
612
|
+
.replace(/[A-Fa-f0-9]{32,}/g, '[redacted]');
|
|
613
|
+
}
|
|
614
|
+
class RollingTail {
|
|
615
|
+
budgetBytes;
|
|
616
|
+
content = '';
|
|
617
|
+
constructor(budgetBytes) {
|
|
618
|
+
this.budgetBytes = budgetBytes;
|
|
619
|
+
}
|
|
620
|
+
push(chunk) {
|
|
621
|
+
this.content =
|
|
622
|
+
(this.content + (typeof chunk === 'string' ? chunk : chunk.toString('utf8'))).slice(-this.budgetBytes);
|
|
623
|
+
}
|
|
624
|
+
text() {
|
|
625
|
+
return this.content;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** SIGTERM → grace → SIGKILL on a live ChildProcess handle. */
|
|
629
|
+
async function terminateChild(proc, graceMs = STOP_GRACE_MS) {
|
|
630
|
+
if (proc.exitCode !== null || proc.signalCode !== null)
|
|
631
|
+
return;
|
|
632
|
+
const exited = new Promise((resolve) => {
|
|
633
|
+
if (proc.exitCode !== null || proc.signalCode !== null)
|
|
634
|
+
resolve();
|
|
635
|
+
else
|
|
636
|
+
proc.once('exit', () => resolve());
|
|
637
|
+
});
|
|
638
|
+
proc.kill('SIGTERM');
|
|
639
|
+
const died = await Promise.race([
|
|
640
|
+
exited.then(() => true),
|
|
641
|
+
delay(graceMs).then(() => false),
|
|
642
|
+
]);
|
|
643
|
+
if (!died) {
|
|
644
|
+
proc.kill('SIGKILL');
|
|
645
|
+
await Promise.race([exited, delay(1_000)]);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function isPidAlive(pid) {
|
|
649
|
+
try {
|
|
650
|
+
process.kill(pid, 0);
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
return err.code === 'EPERM';
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
async function waitForPidDeath(pid, budgetMs) {
|
|
658
|
+
const deadline = Date.now() + budgetMs;
|
|
659
|
+
while (Date.now() < deadline) {
|
|
660
|
+
if (!isPidAlive(pid))
|
|
661
|
+
return true;
|
|
662
|
+
await delay(PID_DEATH_POLL_MS);
|
|
663
|
+
}
|
|
664
|
+
return !isPidAlive(pid);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* L2 stale-pidfile semantics (own implementation, mirroring hub-manager
|
|
668
|
+
* behavior without importing it): if the pidfile names a LIVE process it is an
|
|
669
|
+
* orphan from a previous run — SIGTERM → 3s → SIGKILL — then remove the
|
|
670
|
+
* pidfile. Dead/stale/garbled pidfiles are simply unlinked.
|
|
671
|
+
*/
|
|
672
|
+
export async function reapOrStopByPidFile(pidfile, graceMs = STOP_GRACE_MS) {
|
|
673
|
+
let raw;
|
|
674
|
+
try {
|
|
675
|
+
raw = await fs.readFile(pidfile, 'utf8');
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
let pid;
|
|
681
|
+
try {
|
|
682
|
+
pid = JSON.parse(raw).pid;
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
pid = Number.NaN;
|
|
686
|
+
}
|
|
687
|
+
const parsed = typeof pid === 'number' ? pid : Number.NaN;
|
|
688
|
+
const valid = Number.isInteger(parsed) && parsed > 1 && parsed !== process.pid;
|
|
689
|
+
if (valid && isPidAlive(parsed)) {
|
|
690
|
+
try {
|
|
691
|
+
process.kill(parsed, 'SIGTERM');
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
// Lost the race — nothing to signal.
|
|
695
|
+
}
|
|
696
|
+
if (!(await waitForPidDeath(parsed, graceMs))) {
|
|
697
|
+
try {
|
|
698
|
+
process.kill(parsed, 'SIGKILL');
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Already gone.
|
|
702
|
+
}
|
|
703
|
+
await waitForPidDeath(parsed, 1_000);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
await fs.rm(pidfile, { force: true }).catch(() => { });
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Stop the panel previously started under `dataDir`: SIGTERM → 3s → SIGKILL,
|
|
710
|
+
* then remove the pidfile. Idempotent; safe when no pidfile exists.
|
|
711
|
+
*/
|
|
712
|
+
export async function stopPanel(dataDir) {
|
|
713
|
+
await reapOrStopByPidFile(pidFilePath(dataDir));
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=panel-manager.js.map
|