@jacebenson/jsn 4.3.7-dev.34 → 4.3.7-dev.36
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/README.md +13 -0
- package/package.json +1 -1
- package/src/commands/midservers.js +151 -24
- package/src/midserver-direct.js +263 -0
- package/src/midserver-zip.js +107 -0
package/README.md
CHANGED
|
@@ -31,6 +31,19 @@ npm install -g @jacebenson/jsn
|
|
|
31
31
|
|
|
32
32
|
**Note:** git installs build `better-sqlite3` (docs search) from source, so the machine needs build tools (Python, make, a C++ compiler).
|
|
33
33
|
|
|
34
|
+
## Direct MID installation
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
jsn midservers add --install-method direct --name worker1
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Official x86-64 ZIPs for Linux and Windows, selected from the instance's
|
|
41
|
+
`mid.version`. Credentials are prompted and validated before installation.
|
|
42
|
+
Linux supports a user-owned background runtime. Windows currently prepares
|
|
43
|
+
private files and offers a foreground console, not native service registration.
|
|
44
|
+
See [direct MID installation](docs/mid-direct-install.md) for security boundaries,
|
|
45
|
+
lifecycle commands and the untested Windows host phases. Docker remains available.
|
|
46
|
+
|
|
34
47
|
## Quick Start
|
|
35
48
|
|
|
36
49
|
```bash
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
3
4
|
import { input, password as passwordPrompt, select } from '@inquirer/prompts';
|
|
4
5
|
import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt, assertSafeExactMatch } from '../helpers.js';
|
|
5
6
|
import { FormatAuto } from '../output.js';
|
|
6
7
|
import { declareCapabilities } from '../capabilities.js';
|
|
7
8
|
import { resolveRecord, unwrapSysId } from '../resolve-record.js';
|
|
8
|
-
import { detectDocker, defaultRun, installDockerMidServer, midInstallDirectory, validateMidCredentials } from '../midserver-install.js';
|
|
9
|
+
import { detectDocker, defaultRun, installDockerMidServer, midInstallDirectory, normalizeMidName, validateMidCredentials } from '../midserver-install.js';
|
|
10
|
+
import { detectDirectPlatform, installDirectMidServer, readDirectInstallation, removeDirectInstallation, runDirectInstallation } from '../midserver-direct.js';
|
|
9
11
|
|
|
10
12
|
const AGENT_TABLE = 'ecc_agent';
|
|
11
13
|
const QUEUE_TABLE = 'ecc_queue';
|
|
@@ -13,7 +15,7 @@ const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'ver
|
|
|
13
15
|
const DEFAULT_TIMEOUT = 120000;
|
|
14
16
|
const DEFAULT_INTERVAL = 2000;
|
|
15
17
|
|
|
16
|
-
declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute'] });
|
|
18
|
+
declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop'] });
|
|
17
19
|
|
|
18
20
|
function agentName(record) {
|
|
19
21
|
const name = getStringField(record, 'name');
|
|
@@ -170,6 +172,40 @@ function midDiagnosticsCommand(kind, wrap) {
|
|
|
170
172
|
};
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
// Progress for long, otherwise-silent direct-install phases. Human terminals
|
|
176
|
+
// only, and on stderr, so the stdout envelope and --json output stay clean.
|
|
177
|
+
export function directProgress(app) {
|
|
178
|
+
const human = app.output?.getFormat() === FormatAuto && (canPrompt() || process.stderr.isTTY === true);
|
|
179
|
+
if (!human) return () => {};
|
|
180
|
+
const mib = bytes => (Number(bytes || 0) / (1024 * 1024)).toFixed(0);
|
|
181
|
+
return event => {
|
|
182
|
+
if (event.phase === 'download-start') {
|
|
183
|
+
process.stderr.write(`Downloading the official MID ZIP from ServiceNow (about 532 MiB).\n ${event.archiveURL}\n`);
|
|
184
|
+
} else if (event.phase === 'download') {
|
|
185
|
+
const percent = event.total ? ` (${Math.floor((event.received / event.total) * 100)}%)` : '';
|
|
186
|
+
process.stderr.write(`\r ${mib(event.received)} MiB${percent} downloaded...`);
|
|
187
|
+
if (event.done) process.stderr.write('\n');
|
|
188
|
+
} else if (event.phase === 'extract-start') {
|
|
189
|
+
process.stderr.write(`Extracting ${event.entries} files into ${event.installDir}...\n`);
|
|
190
|
+
} else if (event.phase === 'extract-done') {
|
|
191
|
+
process.stderr.write('Extraction complete. Configuring the MID Server...\n');
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A registration wait is minutes of silence otherwise; report elapsed progress.
|
|
197
|
+
export function registrationProgress(app, timeoutMs) {
|
|
198
|
+
const human = app.output?.getFormat() === FormatAuto && (canPrompt() || process.stderr.isTTY === true);
|
|
199
|
+
if (!human) return undefined;
|
|
200
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
201
|
+
let lastReport = 0;
|
|
202
|
+
return elapsedMs => {
|
|
203
|
+
if (elapsedMs - lastReport < 15000) return;
|
|
204
|
+
lastReport = elapsedMs;
|
|
205
|
+
process.stderr.write(`Waiting for ServiceNow to register the MID Server... ${Math.round(elapsedMs / 1000)}s of ${seconds}s\n`);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
173
209
|
async function listAgents(app, argv) {
|
|
174
210
|
const columns = argv.columns
|
|
175
211
|
? [...new Set(argv.columns.split(',').map(column => column.trim()).filter(Boolean))]
|
|
@@ -231,7 +267,7 @@ export function assertMidServerReady(record) {
|
|
|
231
267
|
const name = getStringField(record, 'name') || getStringField(record, 'sys_id') || 'MID Server';
|
|
232
268
|
const validated = queueValue(record.validated).toLowerCase();
|
|
233
269
|
if (validated !== 'true') {
|
|
234
|
-
const error = new Error(`MID Server '${name}' is not validated yet (validation: ${validated || 'unknown'}).
|
|
270
|
+
const error = new Error(`MID Server '${name}' is not validated yet (validation: ${validated || 'unknown'}). Use the manual Validate action in ServiceNow before executing a command.`);
|
|
235
271
|
error.code = 'midserver_not_validated';
|
|
236
272
|
throw error;
|
|
237
273
|
}
|
|
@@ -265,15 +301,16 @@ async function promptMidCredentials(app, argv) {
|
|
|
265
301
|
}
|
|
266
302
|
}
|
|
267
303
|
|
|
268
|
-
export async function waitForMidServerUp(app, name, timeoutMs, intervalMs) {
|
|
304
|
+
export async function waitForMidServerUp(app, name, timeoutMs, intervalMs, onWait) {
|
|
269
305
|
const started = Date.now();
|
|
270
306
|
const fullName = name;
|
|
271
307
|
while (Date.now() - started <= timeoutMs) {
|
|
308
|
+
onWait?.(Date.now() - started);
|
|
272
309
|
const params = new URLSearchParams({
|
|
273
310
|
sysparm_query: `name=${fullName}`,
|
|
274
311
|
sysparm_limit: '1',
|
|
275
312
|
sysparm_display_value: 'all',
|
|
276
|
-
sysparm_fields: 'sys_id,name,status,host_name,version,last_heartbeat',
|
|
313
|
+
sysparm_fields: 'sys_id,name,status,validated,host_name,version,last_heartbeat',
|
|
277
314
|
});
|
|
278
315
|
const records = await app.sdk.list(AGENT_TABLE, params);
|
|
279
316
|
if (records[0] && getStringField(records[0], 'status').toLowerCase() === 'up') return records[0];
|
|
@@ -282,11 +319,16 @@ export async function waitForMidServerUp(app, name, timeoutMs, intervalMs) {
|
|
|
282
319
|
throw new Error(`MID Server '${fullName}' did not report Up within ${timeoutMs}ms`);
|
|
283
320
|
}
|
|
284
321
|
|
|
285
|
-
function removeLocalMidServer(instanceURL, name) {
|
|
286
|
-
const
|
|
322
|
+
function removeLocalMidServer(instanceURL, name, directory) {
|
|
323
|
+
const direct = readDirectInstallation(instanceURL, name, directory);
|
|
324
|
+
if (direct) { removeDirectInstallation(direct); return true; }
|
|
325
|
+
const installDir = directory || midInstallDirectory(instanceURL, name);
|
|
287
326
|
const composePath = path.join(installDir, 'docker-compose.yaml');
|
|
288
327
|
const envPath = path.join(installDir, '.env');
|
|
289
|
-
if (!fs.existsSync(composePath))
|
|
328
|
+
if (!fs.existsSync(composePath)) {
|
|
329
|
+
if (fs.existsSync(path.join(installDir, 'agent'))) throw new Error('Native MID directory exists without trustworthy ownership metadata; refusing removal');
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
290
332
|
const composeArgs = ['compose', ...(fs.existsSync(envPath) ? ['--env-file', envPath] : []), '-f', composePath, 'down'];
|
|
291
333
|
try {
|
|
292
334
|
defaultRun('docker', composeArgs, { cwd: installDir });
|
|
@@ -303,7 +345,26 @@ function removeLocalMidServer(instanceURL, name) {
|
|
|
303
345
|
}
|
|
304
346
|
|
|
305
347
|
function showMidServerLogs(app, identifier, argv) {
|
|
306
|
-
const installDir = midInstallDirectory(app.getEffectiveInstance(), identifier);
|
|
348
|
+
const installDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), identifier);
|
|
349
|
+
const direct = readDirectInstallation(app.getEffectiveInstance(), identifier, argv.directory);
|
|
350
|
+
if (direct) {
|
|
351
|
+
if (!Number.isInteger(argv.tail) || argv.tail < 1 || argv.tail > 10000) throw new Error('Use --tail between 1 and 10000');
|
|
352
|
+
if (argv.follow) throw new Error('Direct log follow is not supported; read logs again or use the native host log viewer');
|
|
353
|
+
const logfile = path.join(installDir, 'agent', 'logs', 'agent0.log');
|
|
354
|
+
if (!fs.existsSync(logfile)) throw new Error('No MID agent log yet; the installation may only be prepared');
|
|
355
|
+
const stat = fs.lstatSync(logfile);
|
|
356
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Untrusted MID log path');
|
|
357
|
+
const fd = fs.openSync(logfile, 'r');
|
|
358
|
+
let text;
|
|
359
|
+
try {
|
|
360
|
+
const length = Math.min(stat.size, 1024 * 1024);
|
|
361
|
+
const buffer = Buffer.alloc(length);
|
|
362
|
+
fs.readSync(fd, buffer, 0, length, stat.size - length);
|
|
363
|
+
text = buffer.toString('utf8').split('\n').slice(-argv.tail).join('\n');
|
|
364
|
+
} finally { fs.closeSync(fd); }
|
|
365
|
+
app.ok({ installation: 'direct', logfile, text, _formatted: `${text}\n` }, { summary: 'Local MID agent log (last 1 MiB maximum)' });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
307
368
|
const composePath = path.join(installDir, 'docker-compose.yaml');
|
|
308
369
|
const envPath = path.join(installDir, '.env');
|
|
309
370
|
if (!fs.existsSync(composePath)) throw new Error(`No local Compose installation found for MID Server '${identifier}'`);
|
|
@@ -349,24 +410,48 @@ export function midserversCmd(wrap) {
|
|
|
349
410
|
})
|
|
350
411
|
.command({
|
|
351
412
|
command: 'logs <identifier>',
|
|
352
|
-
describe: 'Show local Docker Compose
|
|
413
|
+
describe: 'Show local native or Docker Compose MID logs',
|
|
353
414
|
builder: (b) => b
|
|
354
415
|
.option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
|
|
355
|
-
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
|
|
416
|
+
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs (Compose only)' })
|
|
417
|
+
.option('directory', { type: 'string', describe: 'Local installation directory override' }),
|
|
356
418
|
handler: wrap(async (argv, app) => {
|
|
357
419
|
showMidServerLogs(app, argv.identifier, argv);
|
|
358
420
|
}),
|
|
359
421
|
})
|
|
360
422
|
.command({
|
|
361
423
|
command: '<identifier> logs',
|
|
362
|
-
describe: 'Show local Docker Compose
|
|
424
|
+
describe: 'Show local native or Docker Compose MID logs',
|
|
363
425
|
builder: (b) => b
|
|
364
426
|
.option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
|
|
365
|
-
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
|
|
427
|
+
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs (Compose only)' })
|
|
428
|
+
.option('directory', { type: 'string', describe: 'Local installation directory override' }),
|
|
366
429
|
handler: wrap(async (argv, app) => {
|
|
367
430
|
showMidServerLogs(app, argv.identifier, argv);
|
|
368
431
|
}),
|
|
369
432
|
})
|
|
433
|
+
.command({
|
|
434
|
+
command: 'start <identifier>',
|
|
435
|
+
describe: 'Start an owned direct MID (Windows: foreground console, no service)',
|
|
436
|
+
builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
|
|
437
|
+
handler: wrap(async (argv, app) => {
|
|
438
|
+
const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
|
|
439
|
+
if (!metadata) throw new Error('No owned direct MID installation found');
|
|
440
|
+
if (metadata.state !== 'prepared') throw new Error('MID is already started or has an uncertain runtime state; stop it first');
|
|
441
|
+
const result = runDirectInstallation(metadata, 'start');
|
|
442
|
+
app.ok(result, { summary: result.platform === 'windows' ? 'Windows MID console exited; no service was registered' : 'MID start command completed; registration and manual validation must be checked in ServiceNow' });
|
|
443
|
+
}),
|
|
444
|
+
})
|
|
445
|
+
.command({
|
|
446
|
+
command: 'stop <identifier>',
|
|
447
|
+
describe: 'Stop an owned Linux direct MID; Windows console uses Ctrl+C',
|
|
448
|
+
builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
|
|
449
|
+
handler: wrap(async (argv, app) => {
|
|
450
|
+
const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
|
|
451
|
+
if (!metadata) throw new Error('No owned direct MID installation found');
|
|
452
|
+
app.ok(runDirectInstallation(metadata, 'stop'), { summary: 'Owned native MID stop command completed' });
|
|
453
|
+
}),
|
|
454
|
+
})
|
|
370
455
|
.command({
|
|
371
456
|
command: 'add',
|
|
372
457
|
describe: 'Install and verify a MID Server',
|
|
@@ -374,7 +459,7 @@ export function midserversCmd(wrap) {
|
|
|
374
459
|
.option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
|
|
375
460
|
.option('username', { type: 'string', describe: 'MID Server username (password is always prompted)' })
|
|
376
461
|
.option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
|
|
377
|
-
.option('directory', { type: 'string', describe: '
|
|
462
|
+
.option('directory', { type: 'string', describe: 'Installation directory (must not exist for direct ZIP)' })
|
|
378
463
|
.option('wait-timeout', { type: 'number', default: 300000, describe: 'How long to wait for the MID Server to report Up' })
|
|
379
464
|
.option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
|
|
380
465
|
handler: wrap(async (argv, app) => {
|
|
@@ -395,20 +480,60 @@ export function midserversCmd(wrap) {
|
|
|
395
480
|
choices: dockerAvailable
|
|
396
481
|
? [
|
|
397
482
|
{ name: 'Docker (recommended)', value: 'docker' },
|
|
398
|
-
{ name: 'Direct install (
|
|
483
|
+
{ name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
|
|
399
484
|
]
|
|
400
485
|
: dockerStatus.installed && dockerStatus.compose
|
|
401
486
|
? [
|
|
402
487
|
{ name: 'Run with sudo', value: 'sudo' },
|
|
403
488
|
{ name: 'Prepare files now, run Docker later', value: 'prepare' },
|
|
404
|
-
{ name: 'Direct install (
|
|
489
|
+
{ name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
|
|
405
490
|
]
|
|
406
491
|
: [
|
|
407
492
|
{ name: 'Prepare files now, run Docker later', value: 'prepare' },
|
|
408
|
-
{ name: 'Direct install (
|
|
493
|
+
{ name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
|
|
409
494
|
],
|
|
410
495
|
});
|
|
411
|
-
if (installMethod === 'direct')
|
|
496
|
+
if (installMethod === 'direct') {
|
|
497
|
+
const native = detectDirectPlatform();
|
|
498
|
+
const decision = await select({
|
|
499
|
+
message: native === 'linux' ? 'No system service or elevation will be used. Choose runtime:' : 'Windows console runtime only; native service registration is not supported. Choose:',
|
|
500
|
+
choices: [
|
|
501
|
+
...(native === 'linux' ? [{ name: 'Start user-owned background MID', value: 'start' }] : []),
|
|
502
|
+
{ name: 'Prepare private files only (do not start)', value: 'prepare' },
|
|
503
|
+
{ name: 'Cancel without downloading', value: 'cancel' },
|
|
504
|
+
],
|
|
505
|
+
});
|
|
506
|
+
if (decision === 'cancel') return;
|
|
507
|
+
const shortName = normalizeMidName(name);
|
|
508
|
+
const existing = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${shortName}`, sysparm_fields: 'sys_id,name', sysparm_limit: '1' }));
|
|
509
|
+
if (existing.length) throw new Error(`MID '${shortName}' already exists in ServiceNow; choose a new name. Existing runtime and record were not changed.`);
|
|
510
|
+
const installation = await installDirectMidServer({
|
|
511
|
+
instanceURL: app.getEffectiveInstance(), ...credentials, name, directory: argv.directory,
|
|
512
|
+
runNow: decision === 'start', onProgress: directProgress(app),
|
|
513
|
+
});
|
|
514
|
+
if (installation.state === 'prepared') {
|
|
515
|
+
app.ok(installation, { summary: `Prepared native MID '${installation.name}' in ${installation.installDir}; not started or registered. Start with jsn midservers start ${installation.name}${argv.directory ? ' --directory ' + JSON.stringify(installation.installDir) : ''}. Windows start runs in the foreground console; no service is installed.` });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
let record;
|
|
519
|
+
const directWait = Math.max(1, argv['wait-timeout']);
|
|
520
|
+
const directInterval = Math.max(500, argv['wait-interval']);
|
|
521
|
+
const onWait = registrationProgress(app, directWait);
|
|
522
|
+
const started = Date.now();
|
|
523
|
+
try {
|
|
524
|
+
record = await waitForMidServerUp(app, installation.name, directWait, directInterval, onWait);
|
|
525
|
+
onWait?.(directWait);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
onWait?.(Math.max(directWait, Date.now() - started));
|
|
529
|
+
app.ok({ ...installation, registration: 'pending', validated: false }, { summary: `Native MID start command completed; registration is pending. Runtime retained for diagnostics at ${installation.installDir}; no forced teardown.` });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const validated = queueValue(record.validated).toLowerCase() === 'true';
|
|
533
|
+
const url = new URL(`/ecc_agent.do?sys_id=${encodeURIComponent(unwrapSysId(record))}`, app.getEffectiveInstance()).href;
|
|
534
|
+
app.ok({ ...installation, registration: 'up', validated, record, url }, { summary: validated ? `MID '${installation.name}' is Up and manually validated` : `MID '${installation.name}' is Up but pending manual validation. Use Validate in ServiceNow: ${url}` });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
412
537
|
|
|
413
538
|
const installation = await installDockerMidServer({
|
|
414
539
|
instanceURL: app.getEffectiveInstance(),
|
|
@@ -448,7 +573,8 @@ export function midserversCmd(wrap) {
|
|
|
448
573
|
command: 'remove [identifier]',
|
|
449
574
|
aliases: ['delete'],
|
|
450
575
|
describe: 'Remove a MID Server record (interactive when no identifier is given)',
|
|
451
|
-
builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
|
|
576
|
+
builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
|
|
577
|
+
.option('directory', { type: 'string', describe: 'Local installation directory override' }),
|
|
452
578
|
handler: wrap(async (argv, app) => {
|
|
453
579
|
let record;
|
|
454
580
|
if (argv.identifier) {
|
|
@@ -456,8 +582,8 @@ export function midserversCmd(wrap) {
|
|
|
456
582
|
record = await resolveMidServer(app.sdk, argv.identifier);
|
|
457
583
|
} catch (error) {
|
|
458
584
|
if (error.code !== 'not_found') throw error;
|
|
459
|
-
const localDir = midInstallDirectory(app.getEffectiveInstance(), argv.identifier);
|
|
460
|
-
if (!fs.existsSync(path.join(localDir, 'docker-compose.yaml'))) throw error;
|
|
585
|
+
const localDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), argv.identifier);
|
|
586
|
+
if (!readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory) && !fs.existsSync(path.join(localDir, 'docker-compose.yaml'))) throw error;
|
|
461
587
|
record = null;
|
|
462
588
|
}
|
|
463
589
|
} else {
|
|
@@ -483,9 +609,10 @@ export function midserversCmd(wrap) {
|
|
|
483
609
|
const sysId = record ? unwrapSysId(record) : null;
|
|
484
610
|
const name = getStringField(record, 'name') || argv.identifier || sysId;
|
|
485
611
|
await confirmDelete(app, argv, `Remove MID Server '${name}'`);
|
|
486
|
-
const
|
|
612
|
+
const direct = readDirectInstallation(app.getEffectiveInstance(), name, argv.directory);
|
|
613
|
+
const localRemoved = removeLocalMidServer(app.getEffectiveInstance(), name, argv.directory);
|
|
487
614
|
if (record) await app.sdk.delete(AGENT_TABLE, sysId);
|
|
488
|
-
app.ok({ name, sys_id: sysId, deleted: Boolean(record), local_compose_removed: localRemoved }, { summary: `Removed MID Server '${name}'${localRemoved ? ' and its local
|
|
615
|
+
app.ok({ name, sys_id: sysId, deleted: Boolean(record), local_compose_removed: localRemoved && !direct, local_installation_removed: localRemoved, local_installation_type: direct ? 'direct' : 'docker' }, { summary: `Removed MID Server '${name}'${localRemoved ? ' and its owned local runtime' : ''}` });
|
|
489
616
|
}),
|
|
490
617
|
})
|
|
491
618
|
.command({
|
|
@@ -563,7 +690,7 @@ export function midserversCmd(wrap) {
|
|
|
563
690
|
console.log(' issues [name_or_sys_id] Show unresolved issues (--all includes resolved)');
|
|
564
691
|
console.log(' events [name_or_sys_id] Show lifecycle history');
|
|
565
692
|
console.log(' show <name_or_sys_id> Show a MID Server');
|
|
566
|
-
console.log(' add --name <name>
|
|
693
|
+
console.log(' add --name <name> Install a MID Server (Docker or direct ZIP)');
|
|
567
694
|
console.log(' remove <name_or_sys_id> Remove a MID Server record');
|
|
568
695
|
console.log(' execute <name_or_sys_id> Execute a shell command');
|
|
569
696
|
console.log('');
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { midInstallDirectory, normalizeMidName, defaultRun, validateMidCredentials } from './midserver-install.js';
|
|
7
|
+
import { extractMidZip, inspectMidZip } from './midserver-zip.js';
|
|
8
|
+
|
|
9
|
+
const METADATA = '.jsn-mid-direct.json';
|
|
10
|
+
const MAX_ARCHIVE = 1024 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export function detectDirectPlatform(platform = process.platform, arch = process.arch) {
|
|
13
|
+
if (!['linux', 'win32'].includes(platform)) throw new Error('Direct MID installs require Linux and Windows');
|
|
14
|
+
if (arch !== 'x64') throw new Error('Direct MID ZIP installs require x86-64');
|
|
15
|
+
return platform === 'win32' ? 'windows' : 'linux';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildMidZipUrl(releaseName, platform = process.platform) {
|
|
19
|
+
const native = detectDirectPlatform(platform, 'x64');
|
|
20
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(releaseName)) throw new Error('Invalid MID release name');
|
|
21
|
+
const match = [...releaseName.matchAll(/(\d{4})-(\d{2})-(\d{2})|(\d{2})-(\d{2})-(\d{4})/g)].at(-1);
|
|
22
|
+
if (!match) throw new Error('MID release name has no build date');
|
|
23
|
+
const year = match[1] || match[6], month = match[2] || match[4], day = match[3] || match[5];
|
|
24
|
+
const date = new Date(`${year}-${month}-${day}T00:00:00Z`);
|
|
25
|
+
if (Number.isNaN(date.valueOf()) || date.toISOString().slice(0, 10) !== `${year}-${month}-${day}`) throw new Error('Invalid MID build date');
|
|
26
|
+
return `https://install.service-now.com/glide/distribution/builds/package/app-signed/mid/${year}/${month}/${day}/mid.${releaseName}.${native}.x86-64.zip`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function powershell(script, directory) {
|
|
30
|
+
return execFileSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand',
|
|
31
|
+
Buffer.from(`$ErrorActionPreference='Stop'; try {\n${script}\n} catch { exit 1 }`, 'utf16le').toString('base64')], {
|
|
32
|
+
env: { ...process.env, JSN_MID_DIRECTORY: directory }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// No chmod substitute on Windows: protect the DACL before any credential is written.
|
|
37
|
+
function protectDirectory(directory, platform) {
|
|
38
|
+
if (platform === 'linux') { fs.chmodSync(directory, 0o700); return; }
|
|
39
|
+
powershell(`$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
40
|
+
$acl=New-Object System.Security.AccessControl.DirectorySecurity
|
|
41
|
+
$acl.SetOwner($sid)
|
|
42
|
+
$acl.SetAccessRuleProtection($true,$false)
|
|
43
|
+
$rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')
|
|
44
|
+
$acl.AddAccessRule($rule)
|
|
45
|
+
Set-Acl -LiteralPath $env:JSN_MID_DIRECTORY -AclObject $acl
|
|
46
|
+
$actual=Get-Acl -LiteralPath $env:JSN_MID_DIRECTORY
|
|
47
|
+
if (!$actual.AreAccessRulesProtected) { throw 'MID directory ACL is not protected' }
|
|
48
|
+
foreach($r in $actual.Access) { if($r.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value -or $r.AccessControlType -ne 'Allow') { throw 'Unexpected MID directory access rule' } }`, directory);
|
|
49
|
+
assertPrivateWindows(directory);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertPrivateWindows(directory) {
|
|
53
|
+
powershell(`$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
54
|
+
$acl=Get-Acl -LiteralPath $env:JSN_MID_DIRECTORY
|
|
55
|
+
if (!$acl.AreAccessRulesProtected -or @($acl.Access).Count -ne 1) { throw 'MID directory ACL must be private' }
|
|
56
|
+
$r=@($acl.Access)[0]
|
|
57
|
+
if ($r.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value -or $r.AccessControlType -ne 'Allow' -or $r.FileSystemRights -ne 'FullControl') { throw 'Unexpected MID directory access rule' }`, directory);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkParents(directory, platform) {
|
|
61
|
+
let current = path.dirname(directory);
|
|
62
|
+
while (true) {
|
|
63
|
+
if (fs.existsSync(current)) {
|
|
64
|
+
const st = fs.lstatSync(current);
|
|
65
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('Installation ancestors must be real directories');
|
|
66
|
+
if (platform === 'linux' && (st.mode & 0o022) && !(st.mode & 0o1000)) throw new Error('Installation ancestor is writable by other users');
|
|
67
|
+
}
|
|
68
|
+
const parent = path.dirname(current);
|
|
69
|
+
if (parent === current) break;
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function downloadMidZip(url, archive, fetchImpl = fetch, onProgress) {
|
|
75
|
+
const response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(600000) });
|
|
76
|
+
if (!response.ok || !response.body) throw new Error(`MID ZIP download failed (HTTP ${response.status})`);
|
|
77
|
+
const length = Number(response.headers.get('content-length'));
|
|
78
|
+
if (length > MAX_ARCHIVE) throw new Error('MID ZIP exceeds 1 GiB download limit');
|
|
79
|
+
onProgress?.({ phase: 'download', received: 0, total: Number.isFinite(length) ? length : 0 });
|
|
80
|
+
const fd = fs.openSync(archive, 'wx', 0o600);
|
|
81
|
+
let size = 0;
|
|
82
|
+
let reported = 0;
|
|
83
|
+
const step = Math.max(4 * 1024 * 1024, Math.floor((Number.isFinite(length) ? length : MAX_ARCHIVE) / 100));
|
|
84
|
+
try {
|
|
85
|
+
for await (const chunk of response.body) {
|
|
86
|
+
size += chunk.length;
|
|
87
|
+
if (size > MAX_ARCHIVE) throw new Error('MID ZIP exceeds 1 GiB download limit');
|
|
88
|
+
fs.writeSync(fd, chunk);
|
|
89
|
+
if (onProgress && size - reported >= step) {
|
|
90
|
+
reported = size;
|
|
91
|
+
onProgress({ phase: 'download', received: size, total: Number.isFinite(length) ? length : 0 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (length && size !== length) throw new Error('Incomplete MID ZIP download');
|
|
95
|
+
onProgress?.({ phase: 'download', received: size, total: size, done: true });
|
|
96
|
+
} finally { fs.closeSync(fd); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function xmlEscape(value) {
|
|
100
|
+
if ([...String(value)].some(char => char.codePointAt(0) < 32 && !['\t', '\n', '\r'].includes(char))) throw new Error('Invalid XML configuration character');
|
|
101
|
+
return String(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll("'", ''').replaceAll('<', '<').replaceAll('>', '>').replaceAll('\r', ' ').replaceAll('\n', ' ').replaceAll('\t', '	');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function configureMidXml(template, values) {
|
|
105
|
+
// Work only on active parameter elements, never examples in template comments.
|
|
106
|
+
const comments = [];
|
|
107
|
+
let xml = template.replace(/<!--[\s\S]*?-->/g, comment => `<!--JSN_COMMENT_${comments.push(comment) - 1}-->`);
|
|
108
|
+
for (const [name, value] of Object.entries(values)) {
|
|
109
|
+
let found = 0;
|
|
110
|
+
xml = xml.replace(/<parameter\b[^>]*\/>/g, tag => {
|
|
111
|
+
if (!tag.match(/\bname\s*=\s*(["'])(.*?)\1/) || tag.match(/\bname\s*=\s*(["'])(.*?)\1/)[2] !== name) return tag;
|
|
112
|
+
found++;
|
|
113
|
+
const attribute = `value="${xmlEscape(value)}"`;
|
|
114
|
+
return /\bvalue\s*=/.test(tag) ? tag.replace(/\bvalue\s*=\s*(["'])[\s\S]*?\1/, attribute) : tag.replace(/\/>$/, ` ${attribute}/>`);
|
|
115
|
+
});
|
|
116
|
+
if (found > 1) throw new Error(`Duplicate MID configuration field ${name}`);
|
|
117
|
+
if (!found) {
|
|
118
|
+
if (!xml.includes('</parameters>')) throw new Error('Unrecognized vendor config.xml template');
|
|
119
|
+
xml = xml.replace('</parameters>', ` <parameter name="${name}" value="${xmlEscape(value)}"/>\n</parameters>`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return xml.replace(/<!--JSN_COMMENT_(\d+)-->/g, (_, index) => comments[index]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function nativeToken(origin, name, directory) {
|
|
126
|
+
return `jsn_mid_${createHash('sha256').update(`${origin}/${name}/${directory}`).digest('hex').slice(0, 20)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function refuseNativeService(token) {
|
|
130
|
+
for (const filename of [`/etc/systemd/system/${token}.service`, `/run/systemd/system/${token}.service`, `/usr/lib/systemd/system/${token}.service`, `/etc/init/${token}.conf`, `/etc/init.d/${token}`, `/etc/rc.d/init.d/${token}`]) {
|
|
131
|
+
try { fs.lstatSync(filename); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
132
|
+
throw new Error('A native service already uses this MID identity; refusing to operate on it');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function save(metadata) {
|
|
137
|
+
fs.writeFileSync(path.join(metadata.installDir, METADATA), JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function readDirectInstallation(instanceURL, name, directory) {
|
|
141
|
+
const installDir = path.resolve(directory || midInstallDirectory(instanceURL, name));
|
|
142
|
+
try { fs.lstatSync(path.join(installDir, METADATA)); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
143
|
+
checkParents(installDir, process.platform === 'linux' ? 'linux' : 'windows');
|
|
144
|
+
const root = fs.lstatSync(installDir);
|
|
145
|
+
if (!root.isDirectory() || root.isSymbolicLink() || (process.platform === 'linux' && ((root.mode & 0o077) || root.uid !== process.getuid()))) throw new Error('Refusing an untrusted installation directory');
|
|
146
|
+
if (process.platform === 'win32') assertPrivateWindows(installDir);
|
|
147
|
+
const metaPath = path.join(installDir, METADATA);
|
|
148
|
+
const st = fs.lstatSync(metaPath);
|
|
149
|
+
if (!st.isFile() || st.isSymbolicLink() || (process.platform === 'linux' && ((st.mode & 0o077) || st.uid !== process.getuid()))) throw new Error('Untrusted MID installation metadata');
|
|
150
|
+
const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
151
|
+
if (metadata.owner !== 'jsn-mid-direct-v1' || metadata.installDir !== installDir || metadata.instanceURL !== new URL(instanceURL).origin || metadata.name !== normalizeMidName(name) || metadata.platform !== detectDirectPlatform()) throw new Error('MID installation ownership does not match');
|
|
152
|
+
if (fs.lstatSync(path.join(installDir, 'agent')).isSymbolicLink()) throw new Error('Refusing symlink agent');
|
|
153
|
+
return metadata;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function uncertainState(metadata) {
|
|
157
|
+
return `MID '${metadata.name}' runtime state is uncertain after an interrupted lifecycle command. Confirm no vendor MID process from ${metadata.installDir} is running, then rerun; installed files are kept.`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function runDirectInstallation(metadata, action, run = defaultRun) {
|
|
161
|
+
const cwd = path.join(metadata.installDir, 'agent');
|
|
162
|
+
if (!['start', 'stop'].includes(action)) throw new Error('Unsupported MID runtime action');
|
|
163
|
+
if (metadata.state === 'uncertain') throw new Error(uncertainState(metadata));
|
|
164
|
+
if (metadata.runtime !== 'user-background') {
|
|
165
|
+
// Windows console runtime: never register a service, never guess at a state.
|
|
166
|
+
if (action === 'stop') {
|
|
167
|
+
throw new Error(metadata.state === 'console-running' ? 'Windows console MID: stop with Ctrl+C in its owning terminal before removal' : 'Windows console MID is not running');
|
|
168
|
+
}
|
|
169
|
+
if (metadata.state !== 'prepared') throw new Error('MID is already started; stop it before starting again');
|
|
170
|
+
metadata.state = 'console-running'; save(metadata);
|
|
171
|
+
try { run('cmd.exe', ['/d', '/s', '/c', 'bin\\mid.bat console'], { cwd }); }
|
|
172
|
+
catch { throw new Error('Windows MID console failed or was interrupted; runtime state is uncertain. Stop in its owning terminal; files retained.'); }
|
|
173
|
+
metadata.state = 'prepared'; save(metadata);
|
|
174
|
+
return metadata;
|
|
175
|
+
}
|
|
176
|
+
// Never guess at a state an interrupted Linux command left behind; make it explicit.
|
|
177
|
+
if (['starting', 'stopping', 'console-running'].includes(metadata.state)) {
|
|
178
|
+
metadata.state = 'uncertain'; save(metadata);
|
|
179
|
+
throw new Error(uncertainState(metadata));
|
|
180
|
+
}
|
|
181
|
+
// Call the native user-owned launcher, never install/register a system service.
|
|
182
|
+
if (process.getuid?.() === 0) throw new Error('Run native MID lifecycle commands as a non-root user');
|
|
183
|
+
refuseNativeService(nativeToken(metadata.instanceURL, metadata.name, metadata.installDir));
|
|
184
|
+
if (action === 'start' && metadata.state !== 'prepared') throw new Error('MID is already started; stop it before starting again');
|
|
185
|
+
if (action === 'stop' && metadata.state !== 'started') throw new Error('MID is not running');
|
|
186
|
+
metadata.state = action === 'start' ? 'starting' : 'stopping'; save(metadata);
|
|
187
|
+
try { run('sh', ['bin/mid.sh', action], { cwd, stdio: 'ignore' }); }
|
|
188
|
+
catch { throw new Error(`MID ${action} failed; installation retained at ${metadata.installDir}. Inspect local logs.`); }
|
|
189
|
+
metadata.state = action === 'start' ? 'started' : 'prepared'; save(metadata);
|
|
190
|
+
return metadata;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function removeDirectInstallation(metadata, run = defaultRun) {
|
|
194
|
+
if (metadata.state === 'uncertain') throw new Error(`Refusing to remove '${metadata.name}': runtime state is uncertain. Inspect ${metadata.installDir} and stop any vendor MID process first.`);
|
|
195
|
+
if (metadata.state !== 'prepared') runDirectInstallation(metadata, 'stop', run);
|
|
196
|
+
fs.rmSync(metadata.installDir, { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function installDirectMidServer({ instanceURL, username, password, name, directory, releaseName: suppliedRelease,
|
|
200
|
+
runNow = false, platform = process.platform, arch = process.arch, fetchImpl = fetch, download = downloadMidZip, onProgress,
|
|
201
|
+
protect = protectDirectory, run = defaultRun }) {
|
|
202
|
+
const native = detectDirectPlatform(platform, arch);
|
|
203
|
+
if (native === 'linux' && process.getuid?.() === 0) throw new Error('Run direct MID installation as a non-root user');
|
|
204
|
+
if (native === 'windows' && runNow) throw new Error('Prepare Windows first, then start its console in an owning terminal; no implicit service registration');
|
|
205
|
+
const shortName = normalizeMidName(name);
|
|
206
|
+
const origin = new URL(instanceURL).origin;
|
|
207
|
+
if (!origin.startsWith('https://')) throw new Error('MID credentials require an HTTPS instance');
|
|
208
|
+
const installDir = path.resolve(directory || midInstallDirectory(origin, shortName));
|
|
209
|
+
checkParents(installDir, native);
|
|
210
|
+
if (native === 'linux') refuseNativeService(nativeToken(origin, shortName, installDir));
|
|
211
|
+
if (fs.existsSync(installDir)) throw new Error(`Refusing to overwrite existing installation: ${installDir}`);
|
|
212
|
+
const releaseName = suppliedRelease || (await validateMidCredentials(origin, username, password, fetchImpl)).releaseName;
|
|
213
|
+
const archiveURL = buildMidZipUrl(releaseName, platform);
|
|
214
|
+
// Validate all credential fields before creating files or downloading.
|
|
215
|
+
for (const value of [origin, username, password, shortName]) xmlEscape(value);
|
|
216
|
+
if (!username || !password) throw new Error('MID credentials are required');
|
|
217
|
+
let ancestor = path.dirname(installDir);
|
|
218
|
+
while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor);
|
|
219
|
+
const disk = fs.statfsSync(ancestor);
|
|
220
|
+
if (disk.bavail * disk.bsize < 3 * MAX_ARCHIVE) throw new Error('Direct MID installation requires at least 3 GiB free disk space');
|
|
221
|
+
fs.mkdirSync(path.dirname(installDir), { recursive: true, mode: 0o700 });
|
|
222
|
+
fs.mkdirSync(installDir, { mode: 0o700 }); // atomic collision refusal
|
|
223
|
+
const archive = path.join(installDir, '.download.zip');
|
|
224
|
+
let prepared = false;
|
|
225
|
+
try {
|
|
226
|
+
protect(installDir, native);
|
|
227
|
+
onProgress?.({ phase: 'download-start', archiveURL, platform: native, installDir, name: shortName });
|
|
228
|
+
await download(archiveURL, archive, fetchImpl, onProgress);
|
|
229
|
+
const entries = inspectMidZip(archive);
|
|
230
|
+
onProgress?.({ phase: 'extract-start', entries: entries.length, installDir });
|
|
231
|
+
await extractMidZip(archive, installDir, entries);
|
|
232
|
+
onProgress?.({ phase: 'extract-done', entries: entries.length, installDir });
|
|
233
|
+
const agent = path.join(installDir, 'agent');
|
|
234
|
+
for (const filename of ['config.xml', 'conf/wrapper-override.conf', native === 'linux' ? 'bin/mid.sh' : 'bin/mid.bat']) {
|
|
235
|
+
if (!fs.statSync(path.join(agent, filename)).isFile()) throw new Error(`Vendor ZIP missing ${filename}`);
|
|
236
|
+
}
|
|
237
|
+
const config = path.join(agent, 'config.xml');
|
|
238
|
+
fs.writeFileSync(config, configureMidXml(fs.readFileSync(config, 'utf8'), {
|
|
239
|
+
url: `${origin}/`, 'mid.instance.username': username, 'mid.instance.password': password, name: shortName,
|
|
240
|
+
}), { mode: 0o600 });
|
|
241
|
+
const token = nativeToken(origin, shortName, installDir);
|
|
242
|
+
const override = path.join(agent, 'conf/wrapper-override.conf');
|
|
243
|
+
let text = fs.readFileSync(override, 'utf8').replace(/^wrapper\.(name|displayname)=.*\r?$/gm, '');
|
|
244
|
+
text += `\nwrapper.name=${token}\nwrapper.displayname=ServiceNow MID ${shortName}\n`;
|
|
245
|
+
fs.writeFileSync(override, text, { mode: 0o600 });
|
|
246
|
+
if (native === 'linux') {
|
|
247
|
+
fs.appendFileSync(path.join(agent, 'bin/mid.shconf_override'), `\nAPP_NAME="${token}"\nAPP_LONG_NAME="ServiceNow MID ${shortName}"\nSERVICE_MANAGEMENT_TOOL=initd\n`);
|
|
248
|
+
}
|
|
249
|
+
fs.rmSync(archive);
|
|
250
|
+
const metadata = { owner: 'jsn-mid-direct-v1', instanceURL: origin, name: shortName, releaseName, installDir, archiveURL, platform: native, runtime: native === 'linux' ? 'user-background' : 'console', state: 'prepared', serviceRegistered: false };
|
|
251
|
+
save(metadata);
|
|
252
|
+
prepared = true;
|
|
253
|
+
if (runNow) runDirectInstallation(metadata, 'start', run);
|
|
254
|
+
return metadata;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (!prepared) fs.rmSync(installDir, { recursive: true, force: true });
|
|
257
|
+
// Do not expose nested process/fetch errors that could include credential-bearing content.
|
|
258
|
+
if (prepared) throw error;
|
|
259
|
+
// Do not retain raw causes, which can contain credentials.
|
|
260
|
+
// eslint-disable-next-line preserve-caught-error
|
|
261
|
+
throw new Error(`Direct MID preparation failed; owned files cleaned up (${error.code || 'preparation error'}). Check ZIP, disk, permissions and vendor layout.`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createInflateRaw } from 'node:zlib';
|
|
4
|
+
import { Transform } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
|
|
7
|
+
const MAX_EXPANDED = 2 * 1024 * 1024 * 1024;
|
|
8
|
+
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
9
|
+
for (let i = 0; i < 8; i++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
|
|
10
|
+
return n >>> 0;
|
|
11
|
+
});
|
|
12
|
+
function read(fd, size, offset) {
|
|
13
|
+
const result = Buffer.alloc(size);
|
|
14
|
+
if (fs.readSync(fd, result, 0, size, offset) !== size) throw new Error('Truncated ZIP');
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Inspect every entry before extracting anything. ZIP64, split ZIPs, links and
|
|
19
|
+
// Windows aliases are deliberately unsupported, not delegated to a shell tool.
|
|
20
|
+
export function inspectMidZip(archive) {
|
|
21
|
+
const fd = fs.openSync(archive, 'r');
|
|
22
|
+
try {
|
|
23
|
+
const size = fs.fstatSync(fd).size;
|
|
24
|
+
if (size < 22) throw new Error('Invalid ZIP');
|
|
25
|
+
const tail = read(fd, Math.min(size, 65557), size - Math.min(size, 65557));
|
|
26
|
+
let end = -1;
|
|
27
|
+
for (let i = tail.length - 22; i >= 0; i--) {
|
|
28
|
+
if (tail.readUInt32LE(i) === 0x06054b50 && i + 22 + tail.readUInt16LE(i + 20) === tail.length) { end = i; break; }
|
|
29
|
+
}
|
|
30
|
+
if (end < 0) throw new Error('Missing ZIP directory');
|
|
31
|
+
const count = tail.readUInt16LE(end + 10), directorySize = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
|
|
32
|
+
if (tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6) || tail.readUInt16LE(end + 8) !== count || count === 65535 || count > 20000 || directorySize > 16 * 1024 * 1024 || offset + directorySize > size - (tail.length - end)) throw new Error('Unsupported ZIP directory');
|
|
33
|
+
const directory = read(fd, directorySize, offset);
|
|
34
|
+
const entries = [], names = new Map();
|
|
35
|
+
let pos = 0, expanded = 0;
|
|
36
|
+
for (let i = 0; i < count; i++) {
|
|
37
|
+
if (pos + 46 > directory.length || directory.readUInt32LE(pos) !== 0x02014b50) throw new Error('Invalid ZIP entry');
|
|
38
|
+
const flags = directory.readUInt16LE(pos + 8), method = directory.readUInt16LE(pos + 10);
|
|
39
|
+
const compressedSize = directory.readUInt32LE(pos + 20), uncompressedSize = directory.readUInt32LE(pos + 24);
|
|
40
|
+
const nameLength = directory.readUInt16LE(pos + 28), extraLength = directory.readUInt16LE(pos + 30), commentLength = directory.readUInt16LE(pos + 32);
|
|
41
|
+
if (pos + 46 + nameLength + extraLength + commentLength > directory.length) throw new Error('Truncated ZIP entry');
|
|
42
|
+
const name = directory.subarray(pos + 46, pos + 46 + nameLength).toString('utf8');
|
|
43
|
+
const mode = directory.readUInt32LE(pos + 38) >>> 16;
|
|
44
|
+
const localOffset = directory.readUInt32LE(pos + 42);
|
|
45
|
+
const folder = name.endsWith('/');
|
|
46
|
+
const parts = name.replace(/\/$/, '').split('/');
|
|
47
|
+
if (!['agent', 'META-INF'].includes(parts[0]) || parts.some(part => !part || part === '.' || part === '..' || /[\\:<>"|?*]/.test(part) || [...part].some(char => char.codePointAt(0) < 32) || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('Unsafe ZIP path');
|
|
48
|
+
const type = mode & 0o170000;
|
|
49
|
+
if (type && type !== 0o100000 && type !== 0o040000) throw new Error('ZIP links and special files are forbidden');
|
|
50
|
+
if ((flags & 1) || ![0, 8].includes(method) || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) throw new Error('Unsupported ZIP encoding');
|
|
51
|
+
expanded += uncompressedSize;
|
|
52
|
+
if (expanded > MAX_EXPANDED) throw new Error('ZIP exceeds 2 GiB expanded limit');
|
|
53
|
+
const key = parts.join('/').toLowerCase();
|
|
54
|
+
if (names.has(key)) throw new Error('Duplicate ZIP path');
|
|
55
|
+
names.set(key, folder);
|
|
56
|
+
const header = read(fd, 30, localOffset);
|
|
57
|
+
if (header.readUInt32LE(0) !== 0x04034b50 || header.readUInt16LE(8) !== method || header.readUInt16LE(6) !== flags) throw new Error('ZIP header mismatch');
|
|
58
|
+
const localName = read(fd, header.readUInt16LE(26), localOffset + 30).toString('utf8');
|
|
59
|
+
if (localName !== name) throw new Error('ZIP path mismatch');
|
|
60
|
+
const dataOffset = localOffset + 30 + header.readUInt16LE(26) + header.readUInt16LE(28);
|
|
61
|
+
if (dataOffset + compressedSize > offset) throw new Error('Invalid ZIP data range');
|
|
62
|
+
entries.push({ name, folder, mode, method, compressedSize, uncompressedSize, dataOffset, crc: directory.readUInt32LE(pos + 16) });
|
|
63
|
+
pos += 46 + nameLength + extraLength + commentLength;
|
|
64
|
+
}
|
|
65
|
+
if (pos !== directory.length) throw new Error('Unexpected ZIP directory data');
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const parts = entry.name.replace(/\/$/, '').toLowerCase().split('/');
|
|
68
|
+
for (let i = 1; i < parts.length; i++) if (names.get(parts.slice(0, i).join('/')) === false) throw new Error('ZIP file/directory collision');
|
|
69
|
+
}
|
|
70
|
+
return entries;
|
|
71
|
+
} finally { fs.closeSync(fd); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function privateDirectory(directory) {
|
|
75
|
+
try { fs.mkdirSync(directory, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
76
|
+
const st = fs.lstatSync(directory);
|
|
77
|
+
if (!st.isDirectory() || st.isSymbolicLink()) throw new Error('Unsafe ZIP destination directory');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function extractMidZip(archive, destination, entries = inspectMidZip(archive)) {
|
|
81
|
+
privateDirectory(destination);
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const parts = entry.name.replace(/\/$/, '').split('/');
|
|
84
|
+
let parent = destination;
|
|
85
|
+
for (const part of parts.slice(0, entry.folder ? parts.length : -1)) {
|
|
86
|
+
parent = path.join(parent, part);
|
|
87
|
+
privateDirectory(parent);
|
|
88
|
+
}
|
|
89
|
+
const filename = path.join(destination, ...parts);
|
|
90
|
+
if (entry.folder) continue;
|
|
91
|
+
let size = 0, crc = 0xffffffff;
|
|
92
|
+
const verify = new Transform({ transform(chunk, _, callback) {
|
|
93
|
+
size += chunk.length;
|
|
94
|
+
if (size > entry.uncompressedSize) return callback(new Error('ZIP expanded size mismatch'));
|
|
95
|
+
for (const byte of chunk) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8);
|
|
96
|
+
callback(null, chunk);
|
|
97
|
+
} });
|
|
98
|
+
const output = fs.createWriteStream(filename, { flags: 'wx', mode: entry.mode & 0o111 ? 0o700 : 0o600 });
|
|
99
|
+
if (!entry.compressedSize) { output.end(); await new Promise((resolve, reject) => { output.on('close', resolve); output.on('error', reject); }); }
|
|
100
|
+
else {
|
|
101
|
+
const input = fs.createReadStream(archive, { start: entry.dataOffset, end: entry.dataOffset + entry.compressedSize - 1 });
|
|
102
|
+
if (entry.method === 8) await pipeline(input, createInflateRaw(), verify, output);
|
|
103
|
+
else await pipeline(input, verify, output);
|
|
104
|
+
}
|
|
105
|
+
if (size !== entry.uncompressedSize || ((crc ^ 0xffffffff) >>> 0) !== entry.crc) throw new Error('ZIP integrity check failed');
|
|
106
|
+
}
|
|
107
|
+
}
|