@jacebenson/jsn 4.3.13-dev.94.1 → 4.3.13-dev.96.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/history.js +34 -3
- package/src/commands/midservers.js +117 -10
- package/src/midserver-linux-remote.js +210 -0
- package/src/midserver-remote-recipe.js +49 -0
- package/src/midserver-remote.js +221 -0
- package/src/midserver-windows-remote.js +318 -0
- package/src/midserver-zip.js +2 -2
package/package.json
CHANGED
package/src/commands/history.js
CHANGED
|
@@ -5,11 +5,10 @@ export function historyCmd(wrap) {
|
|
|
5
5
|
declareCapabilities('history', { noInstance: true, skipDailyChecks: true });
|
|
6
6
|
const list = wrap(async (argv, app) => {
|
|
7
7
|
const entries = listHistory({ ...argv, instance: argv['instance-url'], profile: argv['profile-name'] });
|
|
8
|
-
app.ok({ entries, retentionDays: 7, _formatted: entries.
|
|
9
|
-
? entries.map(e => `${e.timestamp} ${e.operation} ${e.table}/${e.sys_id || '(unknown ID)'} ${e.outcome} ${e.instance}${e.reason ? ` — ${e.reason}` : ''}`).join('\n')
|
|
10
|
-
: 'No local mutations in the last seven days.' });
|
|
8
|
+
app.ok({ entries, retentionDays: 7, _formatted: renderHistory(entries, { details: argv.details }) });
|
|
11
9
|
});
|
|
12
10
|
const filters = b => b
|
|
11
|
+
.option('details', { type: 'boolean', default: false, describe: 'Show full record IDs, instance URLs and reasons' })
|
|
13
12
|
.option('since', { type: 'string', describe: 'Earliest ISO timestamp' })
|
|
14
13
|
.option('until', { type: 'string', describe: 'Latest ISO timestamp' })
|
|
15
14
|
.option('instance-url', { type: 'string', describe: 'Filter by instance origin' })
|
|
@@ -24,3 +23,35 @@ export function historyCmd(wrap) {
|
|
|
24
23
|
app.ok({ ...pruneHistory(), _formatted: 'Expired local history removed; seven days retained.' });
|
|
25
24
|
}) }), handler: list };
|
|
26
25
|
}
|
|
26
|
+
|
|
27
|
+
export function renderHistory(entries, { details = false, timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone } = {}) {
|
|
28
|
+
if (!entries.length) return 'No local mutations in the last seven days.';
|
|
29
|
+
const date = new Intl.DateTimeFormat('en-GB', { timeZone, month: 'short', day: '2-digit',
|
|
30
|
+
hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
31
|
+
// Strip terminal control characters from stored identifiers and labels.
|
|
32
|
+
// eslint-disable-next-line no-control-regex
|
|
33
|
+
const clean = value => String(value ?? '').replace(/[\x00-\x1f\x7f]/g, ' ');
|
|
34
|
+
const clip = (value, length) => value.length > length ? `${value.slice(0, length - 1)}…` : value;
|
|
35
|
+
const rows = entries.map(entry => {
|
|
36
|
+
let connection = entry.profile;
|
|
37
|
+
if (!connection) {
|
|
38
|
+
try { connection = new URL(entry.instance).host; } catch { connection = entry.instance; }
|
|
39
|
+
}
|
|
40
|
+
return [date.format(new Date(entry.timestamp)), entry.operation, clip(clean(entry.table), 22),
|
|
41
|
+
clip(clean(entry.sys_id || '(unknown)'), 12), entry.outcome, clip(clean(connection), 22)];
|
|
42
|
+
});
|
|
43
|
+
const headers = ['WHEN', 'ACTION', 'TABLE', 'RECORD', 'RESULT', 'CONNECTION'];
|
|
44
|
+
const widths = headers.map((header, i) => Math.max(header.length, ...rows.map(row => row[i].length)));
|
|
45
|
+
const format = row => row.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd();
|
|
46
|
+
const lines = [`Recent mutations · ${timeZone}`, '', format(headers)];
|
|
47
|
+
rows.forEach((row, i) => {
|
|
48
|
+
lines.push(format(row));
|
|
49
|
+
if (details) {
|
|
50
|
+
const entry = entries[i];
|
|
51
|
+
lines.push(` ${clean(entry.timestamp)} · ${clean(entry.table)}/${clean(entry.sys_id || '(unknown)')}`,
|
|
52
|
+
` ${clean(entry.instance)}${entry.reason ? ` · ${clean(entry.reason)}` : ''}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
if (!details) lines.push('', 'Use --details for full IDs, URLs and reasons; --json for structured history.');
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
|
-
import { input, password as passwordPrompt, select } from '../prompts.js';
|
|
4
|
+
import { input, password as passwordPrompt, select, confirm } from '../prompts.js';
|
|
5
5
|
import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt, assertSafeExactMatch } from '../helpers.js';
|
|
6
|
+
import { AppError, CodeConfirmationRequired } from '../errors.js';
|
|
7
|
+
import { prepareRemoteMidServer, installRemoteMidServer, startRemoteMidServer, stopRemoteMidServer, logsRemoteMidServer, followRemoteMidServer, removeRemoteMidServer } from '../midserver-remote.js';
|
|
8
|
+
import { prepareWindowsRemoteMidServer, installWindowsRemoteMidServer, startWindowsRemoteMidServer, stopWindowsRemoteMidServer, logsWindowsRemoteMidServer, removeWindowsRemoteMidServer } from '../midserver-windows-remote.js';
|
|
9
|
+
import { prepareLinuxRemoteMidServer, installLinuxRemoteMidServer, startLinuxRemoteMidServer, stopLinuxRemoteMidServer, logsLinuxRemoteMidServer, removeLinuxRemoteMidServer } from '../midserver-linux-remote.js';
|
|
10
|
+
import { downloadRemoteMidRecipe } from '../midserver-remote-recipe.js';
|
|
6
11
|
import { FormatAuto } from '../output.js';
|
|
7
12
|
import { declareCapabilities } from '../capabilities.js';
|
|
8
13
|
import { resolveRecord, unwrapSysId } from '../resolve-record.js';
|
|
@@ -15,7 +20,7 @@ const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'ver
|
|
|
15
20
|
const DEFAULT_TIMEOUT = 120000;
|
|
16
21
|
const DEFAULT_INTERVAL = 2000;
|
|
17
22
|
|
|
18
|
-
declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop'] });
|
|
23
|
+
declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop', 'validate'] });
|
|
19
24
|
|
|
20
25
|
function agentName(record) {
|
|
21
26
|
const name = getStringField(record, 'name');
|
|
@@ -274,6 +279,13 @@ export function assertMidServerReady(record) {
|
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
async function promptMidCredentials(app, argv) {
|
|
282
|
+
if (argv.passwordStdin) {
|
|
283
|
+
if (!argv.username) throw new Error('--password-stdin requires --username');
|
|
284
|
+
const password = fs.readFileSync(0, 'utf8').replace(/\r?\n$/, '');
|
|
285
|
+
if (!password || Buffer.byteLength(password) > 65536 || password.includes('\0')) throw new Error('Invalid password input');
|
|
286
|
+
const validation = await validateMidCredentials(app.getEffectiveInstance(), argv.username, password);
|
|
287
|
+
return { username: argv.username, password, releaseName: validation.releaseName };
|
|
288
|
+
}
|
|
277
289
|
if (!canPrompt()) throw new Error('MID Server installation is interactive. Run it from a terminal.');
|
|
278
290
|
let username = argv.username || '';
|
|
279
291
|
while (true) {
|
|
@@ -344,7 +356,31 @@ function removeLocalMidServer(instanceURL, name, directory) {
|
|
|
344
356
|
return true;
|
|
345
357
|
}
|
|
346
358
|
|
|
359
|
+
function remoteDirectory(app, argv, name) {
|
|
360
|
+
const directory = argv.directory || midInstallDirectory(app.getEffectiveInstance(), name);
|
|
361
|
+
const file = ['.jsn-mid-windows-remote.json', '.jsn-mid-linux-remote.json', '.jsn-mid-remote.json'].find(name => fs.existsSync(path.join(directory, name)));
|
|
362
|
+
if (!file) throw new Error('No owned remote MID metadata found');
|
|
363
|
+
const metadata = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
|
|
364
|
+
if (metadata.target !== argv.ssh || metadata.instanceURL !== app.getEffectiveInstance().replace(/\/$/, '') || metadata.name !== name) {
|
|
365
|
+
throw new Error('Remote MID target, instance or name does not match owned local metadata');
|
|
366
|
+
}
|
|
367
|
+
return directory;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function nativeRemote(directory) {
|
|
371
|
+
if (fs.existsSync(path.join(directory, '.jsn-mid-windows-remote.json'))) return { start: startWindowsRemoteMidServer, stop: stopWindowsRemoteMidServer, logs: logsWindowsRemoteMidServer, remove: removeWindowsRemoteMidServer };
|
|
372
|
+
if (fs.existsSync(path.join(directory, '.jsn-mid-linux-remote.json'))) return { start: startLinuxRemoteMidServer, stop: stopLinuxRemoteMidServer, logs: logsLinuxRemoteMidServer, remove: removeLinuxRemoteMidServer };
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
347
376
|
async function showMidServerLogs(app, identifier, argv) {
|
|
377
|
+
if (argv.ssh) {
|
|
378
|
+
const directory = remoteDirectory(app, argv, identifier);
|
|
379
|
+
if (argv.follow && nativeRemote(directory)) throw new Error('Remote native logs currently support bounded --tail only');
|
|
380
|
+
if (argv.follow) return followRemoteMidServer({ directory, tail: argv.tail });
|
|
381
|
+
const text = (nativeRemote(directory)?.logs || logsRemoteMidServer)({ directory, tail: argv.tail });
|
|
382
|
+
return app.ok({ target: argv.ssh, text, _formatted: text }, { summary: 'Remote MID logs' });
|
|
383
|
+
}
|
|
348
384
|
const installDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), identifier);
|
|
349
385
|
const direct = readDirectInstallation(app.getEffectiveInstance(), identifier, argv.directory);
|
|
350
386
|
if (direct) {
|
|
@@ -420,7 +456,8 @@ export function midserversCmd(wrap) {
|
|
|
420
456
|
builder: (b) => b
|
|
421
457
|
.option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
|
|
422
458
|
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
|
|
423
|
-
.option('directory', { type: 'string', describe: 'Local installation directory override' })
|
|
459
|
+
.option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
|
|
460
|
+
.option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
|
|
424
461
|
handler: wrap(async (argv, app) => {
|
|
425
462
|
await showMidServerLogs(app, argv.identifier, argv);
|
|
426
463
|
}),
|
|
@@ -431,16 +468,19 @@ export function midserversCmd(wrap) {
|
|
|
431
468
|
builder: (b) => b
|
|
432
469
|
.option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
|
|
433
470
|
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
|
|
434
|
-
.option('directory', { type: 'string', describe: 'Local installation directory override' })
|
|
471
|
+
.option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
|
|
472
|
+
.option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
|
|
435
473
|
handler: wrap(async (argv, app) => {
|
|
436
474
|
await showMidServerLogs(app, argv.identifier, argv);
|
|
437
475
|
}),
|
|
438
476
|
})
|
|
439
477
|
.command({
|
|
440
478
|
command: 'start <identifier>',
|
|
441
|
-
describe: 'Start an owned
|
|
442
|
-
builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
|
|
479
|
+
describe: 'Start an owned MID locally or over SSH (remote Windows uses a service)',
|
|
480
|
+
builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
|
|
481
|
+
.option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
|
|
443
482
|
handler: wrap(async (argv, app) => {
|
|
483
|
+
if (argv.ssh) { const directory = remoteDirectory(app, argv, argv.identifier); return app.ok((nativeRemote(directory)?.start || startRemoteMidServer)({ directory }), { summary: 'Remote MID start command completed' }); }
|
|
444
484
|
const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
|
|
445
485
|
if (!metadata) throw new Error('No owned direct MID installation found');
|
|
446
486
|
if (metadata.state !== 'prepared') throw new Error('MID is already started or has an uncertain runtime state; stop it first');
|
|
@@ -450,9 +490,11 @@ export function midserversCmd(wrap) {
|
|
|
450
490
|
})
|
|
451
491
|
.command({
|
|
452
492
|
command: 'stop <identifier>',
|
|
453
|
-
describe: 'Stop an owned
|
|
454
|
-
builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
|
|
493
|
+
describe: 'Stop an owned MID locally or over SSH (local Windows console uses Ctrl+C)',
|
|
494
|
+
builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
|
|
495
|
+
.option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
|
|
455
496
|
handler: wrap(async (argv, app) => {
|
|
497
|
+
if (argv.ssh) { const directory = remoteDirectory(app, argv, argv.identifier); return app.ok((nativeRemote(directory)?.stop || stopRemoteMidServer)({ directory }), { summary: 'Remote MID stop command completed' }); }
|
|
456
498
|
const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
|
|
457
499
|
if (!metadata) throw new Error('No owned direct MID installation found');
|
|
458
500
|
app.ok(runDirectInstallation(metadata, 'stop'), { summary: 'Owned native MID stop command completed' });
|
|
@@ -463,13 +505,45 @@ export function midserversCmd(wrap) {
|
|
|
463
505
|
describe: 'Install and verify a MID Server',
|
|
464
506
|
builder: (b) => b
|
|
465
507
|
.option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
|
|
466
|
-
.option('username', { type: 'string', describe: 'MID Server username
|
|
508
|
+
.option('username', { type: 'string', describe: 'MID Server username' })
|
|
509
|
+
.option('password-stdin', { type: 'boolean', describe: 'Read MID password from stdin instead of a terminal prompt' })
|
|
510
|
+
.option('ssh', { type: 'string', describe: 'Install MID on a configured SSH target (Docker, native Windows service or Linux background process)' })
|
|
511
|
+
.option('native-platform', { type: 'string', choices: ['windows', 'linux'], default: 'windows', describe: 'Remote direct ZIP platform (Linux uses a user background process and needs Python 3)' })
|
|
467
512
|
.option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
|
|
468
513
|
.option('directory', { type: 'string', describe: 'Installation directory (must not exist for direct ZIP)' })
|
|
469
514
|
.option('wait-timeout', { type: 'number', default: 300000, describe: 'How long to wait for the MID Server to report Up' })
|
|
470
515
|
.option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
|
|
471
516
|
handler: wrap(async (argv, app) => {
|
|
472
517
|
app.requireInstance();
|
|
518
|
+
if (argv.ssh) {
|
|
519
|
+
if (!argv.name) throw new Error('Remote installation requires --name');
|
|
520
|
+
const name = normalizeMidName(argv.name);
|
|
521
|
+
const existing = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${name}`, sysparm_limit: '1', sysparm_fields: 'sys_id' }));
|
|
522
|
+
if (existing.length) throw new Error('MID name already exists; refusing to adopt an existing MID');
|
|
523
|
+
const credentials = await promptMidCredentials(app, argv);
|
|
524
|
+
const directory = argv.directory || midInstallDirectory(app.getEffectiveInstance(), name);
|
|
525
|
+
fs.mkdirSync(path.dirname(directory), { recursive: true, mode: 0o700 });
|
|
526
|
+
const native = (argv.installMethod || argv['install-method']) === 'direct';
|
|
527
|
+
let installation;
|
|
528
|
+
if (native) {
|
|
529
|
+
const linux = argv.nativePlatform === 'linux';
|
|
530
|
+
const prepare = linux ? prepareLinuxRemoteMidServer : prepareWindowsRemoteMidServer;
|
|
531
|
+
const install = linux ? installLinuxRemoteMidServer : installWindowsRemoteMidServer;
|
|
532
|
+
const start = linux ? startLinuxRemoteMidServer : startWindowsRemoteMidServer;
|
|
533
|
+
prepare({ target: argv.ssh, instanceURL: app.getEffectiveInstance(), name, directory });
|
|
534
|
+
install({ directory, releaseName: credentials.releaseName, username: credentials.username, password: credentials.password });
|
|
535
|
+
installation = start({ directory });
|
|
536
|
+
} else {
|
|
537
|
+
prepareRemoteMidServer({ target: argv.ssh, instanceURL: app.getEffectiveInstance(), name, directory });
|
|
538
|
+
const context = await downloadRemoteMidRecipe(credentials.releaseName);
|
|
539
|
+
installation = installRemoteMidServer({ directory, context, username: credentials.username, password: credentials.password });
|
|
540
|
+
}
|
|
541
|
+
const kind = native ? (argv.nativePlatform === 'linux' ? 'remote-linux-background' : 'remote-windows-service') : 'remote-docker';
|
|
542
|
+
let record;
|
|
543
|
+
try { record = await waitForMidServerUp(app, name, Math.max(1, argv.waitTimeout), Math.max(500, argv.waitInterval)); }
|
|
544
|
+
catch { return app.ok({ installation: kind, target: argv.ssh, directory, name, registration: 'pending' }, { summary: 'Remote MID started; registration is pending. Runtime and ownership metadata retained for logs or explicit teardown.' }); }
|
|
545
|
+
return app.ok({ installation: kind, target: argv.ssh, directory, name, registration: 'up', validated: queueValue(record.validated) === 'true', record, runtime_state: installation.state }, { summary: 'Remote MID is Up; validation is a separate explicit operation' });
|
|
546
|
+
}
|
|
473
547
|
const credentials = await promptMidCredentials(app, argv);
|
|
474
548
|
const name = argv.name || (await input({
|
|
475
549
|
message: 'MID Server name:',
|
|
@@ -580,8 +654,19 @@ export function midserversCmd(wrap) {
|
|
|
580
654
|
aliases: ['delete'],
|
|
581
655
|
describe: 'Remove a MID Server record (interactive when no identifier is given)',
|
|
582
656
|
builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
|
|
583
|
-
.option('directory', { type: 'string', describe: 'Local installation directory override' })
|
|
657
|
+
.option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
|
|
658
|
+
.option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
|
|
584
659
|
handler: wrap(async (argv, app) => {
|
|
660
|
+
if (argv.ssh) {
|
|
661
|
+
if (!argv.identifier) throw new Error('Remote removal requires the owned MID name');
|
|
662
|
+
const directory = remoteDirectory(app, argv, argv.identifier);
|
|
663
|
+
await confirmDelete(app, argv, `Remove owned MID '${argv.identifier}' from SSH target '${argv.ssh}' and delete its MID record`);
|
|
664
|
+
const removed = (nativeRemote(directory)?.remove || removeRemoteMidServer)({ directory });
|
|
665
|
+
const records = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${argv.identifier}`, sysparm_limit: '2', sysparm_fields: 'sys_id,name' }));
|
|
666
|
+
if (records.length > 1) throw new Error('Remote runtime removed; multiple MID records match. Inspect records before deleting any.');
|
|
667
|
+
if (records.length) await app.sdk.delete(AGENT_TABLE, unwrapSysId(records[0]));
|
|
668
|
+
return app.ok({ name: argv.identifier, target: argv.ssh, remote_runtime_removed: removed.state === 'removed', record_deleted: records.length === 1, metadata_directory: directory }, { summary: 'Owned remote MID runtime removed; nonsecret ownership receipt retained' });
|
|
669
|
+
}
|
|
585
670
|
let record;
|
|
586
671
|
if (argv.identifier) {
|
|
587
672
|
try {
|
|
@@ -621,6 +706,28 @@ export function midserversCmd(wrap) {
|
|
|
621
706
|
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' : ''}` });
|
|
622
707
|
}),
|
|
623
708
|
})
|
|
709
|
+
.command({
|
|
710
|
+
command: 'validate <identifier>',
|
|
711
|
+
describe: 'Explicitly trust a MID through the platform Validate action; never writes the validated flag directly',
|
|
712
|
+
builder: b => b.option('timeout', { type: 'number', default: 300000, describe: 'Validation verification timeout in milliseconds' }),
|
|
713
|
+
handler: wrap(async (argv, app) => {
|
|
714
|
+
const record = await resolveMidServer(app.sdk, argv.identifier);
|
|
715
|
+
assertMidServerUp(record);
|
|
716
|
+
if (!argv.force) {
|
|
717
|
+
if (!canPrompt() || app.output.getFormat() !== FormatAuto) throw new AppError(CodeConfirmationRequired, 'MID trust validation requires approval.', 'After approval, rerun with --force.');
|
|
718
|
+
if (!await confirm({ message: `Trust MID '${getStringField(record, 'name')}' using the platform Validate action?`, default: false })) return;
|
|
719
|
+
}
|
|
720
|
+
const cookies = await app.sdk._warmSession(), csrf = await app.sdk._getScriptsPageCSRF(cookies);
|
|
721
|
+
await app.sdk.rawRequest(`${app.sdk.baseURL}/xmlhttp.do`, { method: 'POST', headers: { Cookie: cookies, 'X-UserToken': csrf }, body: new URLSearchParams({ sysparm_processor: 'MIDServerAjax', sysparm_name: 'validateMID', sysparm_agent: unwrapSysId(record), sysparm_ck: csrf }).toString() });
|
|
722
|
+
const started = Date.now();
|
|
723
|
+
while (Date.now() - started < argv.timeout) {
|
|
724
|
+
const current = await resolveMidServer(app.sdk, argv.identifier);
|
|
725
|
+
if (queueValue(current.validated) === 'true') return app.ok({ name: getStringField(current, 'name'), validated: true }, { summary: 'MID trust validation verified' });
|
|
726
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
727
|
+
}
|
|
728
|
+
throw new Error('MID validation was requested but not verified; inspect MID issues before retrying');
|
|
729
|
+
}),
|
|
730
|
+
})
|
|
624
731
|
.command({
|
|
625
732
|
command: 'execute [identifier]',
|
|
626
733
|
describe: 'Execute a shell command through a MID Server',
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { validateRemoteTarget } from './midserver-remote.js';
|
|
7
|
+
import { buildMidZipUrl } from './midserver-direct.js';
|
|
8
|
+
|
|
9
|
+
const FILE = '.jsn-mid-linux-remote.json';
|
|
10
|
+
// Static program only in argv; all deployment data and credentials travel on stdin.
|
|
11
|
+
const PROGRAM = String.raw`
|
|
12
|
+
import os,sys,json,stat,platform,shutil,subprocess,time,zipfile,urllib.request,hashlib,re,xml.etree.ElementTree as ET
|
|
13
|
+
p=json.load(sys.stdin)
|
|
14
|
+
def fail(): raise RuntimeError('Remote native MID safety check failed')
|
|
15
|
+
def result(v): print('JSN_RESULT:'+json.dumps(v))
|
|
16
|
+
def private(d):
|
|
17
|
+
s=os.lstat(d)
|
|
18
|
+
if not stat.S_ISDIR(s.st_mode) or s.st_uid!=os.getuid() or s.st_mode&0o077: fail()
|
|
19
|
+
def ancestors(d):
|
|
20
|
+
while d!='/':
|
|
21
|
+
s=os.lstat(d)
|
|
22
|
+
if not stat.S_ISDIR(s.st_mode) or (s.st_mode&0o022 and not s.st_mode&stat.S_ISVTX): fail()
|
|
23
|
+
d=os.path.dirname(d)
|
|
24
|
+
def owned():
|
|
25
|
+
private(p['remoteDirectory']); ancestors(p['remoteDirectory'])
|
|
26
|
+
f=os.path.join(p['remoteDirectory'],'.jsn-owner.json'); s=os.lstat(f)
|
|
27
|
+
if not stat.S_ISREG(s.st_mode) or s.st_uid!=os.getuid() or s.st_mode&0o077: fail()
|
|
28
|
+
m=json.load(open(f))
|
|
29
|
+
if any(m.get(k)!=p.get(k) for k in ['owner','instanceURL','name','remoteDirectory']): fail()
|
|
30
|
+
def processes():
|
|
31
|
+
found=[]; agent=os.path.join(p['remoteDirectory'],'agent')
|
|
32
|
+
for entry in os.listdir('/proc'):
|
|
33
|
+
if not entry.isdigit(): continue
|
|
34
|
+
try:
|
|
35
|
+
proc='/proc/'+entry
|
|
36
|
+
if os.stat(proc).st_uid!=os.getuid(): continue
|
|
37
|
+
exe=os.readlink(proc+'/exe'); cwd=os.readlink(proc+'/cwd')
|
|
38
|
+
if exe in [agent+'/bin/wrapper-linux-x86-64',agent+'/jre/bin/java'] and cwd==agent: found.append(int(entry))
|
|
39
|
+
except (FileNotFoundError,PermissionError,ProcessLookupError): continue
|
|
40
|
+
return found
|
|
41
|
+
def safe_tree():
|
|
42
|
+
for root,dirs,files in os.walk(p['remoteDirectory'],followlinks=False):
|
|
43
|
+
for name in dirs+files:
|
|
44
|
+
s=os.lstat(os.path.join(root,name))
|
|
45
|
+
if not (stat.S_ISREG(s.st_mode) or stat.S_ISDIR(s.st_mode)) or s.st_uid!=os.getuid(): fail()
|
|
46
|
+
def launcher(action):
|
|
47
|
+
owned(); safe_tree()
|
|
48
|
+
if action=='stop':
|
|
49
|
+
pidfile=os.path.join(p['remoteDirectory'],'agent','work','jsn_mid_'+p['owner'].replace('-','')+'.pid')
|
|
50
|
+
s=os.lstat(pidfile)
|
|
51
|
+
if not stat.S_ISREG(s.st_mode) or s.st_uid!=os.getuid() or s.st_size>64: fail()
|
|
52
|
+
value=open(pidfile).read().strip()
|
|
53
|
+
if not re.fullmatch('[1-9][0-9]*',value) or int(value) not in processes(): fail()
|
|
54
|
+
subprocess.run(['sh','bin/mid.sh',action],cwd=os.path.join(p['remoteDirectory'],'agent'),stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True,timeout=90,start_new_session=True)
|
|
55
|
+
if platform.system()!='Linux' or platform.machine() not in ['x86_64','amd64'] or os.getuid()==0: fail()
|
|
56
|
+
action=p['action']; d=p.get('remoteDirectory')
|
|
57
|
+
if action=='prepare':
|
|
58
|
+
home=os.path.expanduser('~'); ancestors(home)
|
|
59
|
+
if os.lstat(home).st_uid!=os.getuid(): fail()
|
|
60
|
+
d=os.path.join(home,'.jsn-mid',p['owner'])
|
|
61
|
+
if os.path.lexists(d): fail()
|
|
62
|
+
result(dict(remoteDirectory=d,platform='linux-x64',elevated=False,requiresElevation=False,runtime='user-background',freeBytes=shutil.disk_usage(home).free,python=platform.python_version()))
|
|
63
|
+
elif action=='install':
|
|
64
|
+
if os.path.lexists(d): fail()
|
|
65
|
+
parent=os.path.dirname(d)
|
|
66
|
+
if not os.path.exists(parent): os.mkdir(parent,0o700)
|
|
67
|
+
private(parent); ancestors(parent)
|
|
68
|
+
if shutil.disk_usage(parent).free<3*1024**3: fail()
|
|
69
|
+
os.mkdir(d,0o700); os.umask(0o077)
|
|
70
|
+
marker={k:p[k] for k in ['owner','instanceURL','name','remoteDirectory']}
|
|
71
|
+
with open(os.path.join(d,'.jsn-owner.json'),'x') as f: json.dump(marker,f)
|
|
72
|
+
archive=os.path.join(d,'.download.zip')
|
|
73
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
74
|
+
def redirect_request(self,*args): fail()
|
|
75
|
+
opener=urllib.request.build_opener(NoRedirect)
|
|
76
|
+
total=0; digest=hashlib.sha256()
|
|
77
|
+
with opener.open(p['archiveURL'],timeout=600) as source,open(archive,'xb') as dest:
|
|
78
|
+
while True:
|
|
79
|
+
chunk=source.read(1024*1024)
|
|
80
|
+
if not chunk: break
|
|
81
|
+
total+=len(chunk)
|
|
82
|
+
if total>1024**3: fail()
|
|
83
|
+
digest.update(chunk); dest.write(chunk)
|
|
84
|
+
with zipfile.ZipFile(archive) as z:
|
|
85
|
+
entries=z.infolist(); seen=set(); expanded=0
|
|
86
|
+
if len(entries)>20000: fail()
|
|
87
|
+
for e in entries:
|
|
88
|
+
n=e.filename; parts=n.rstrip('/').split('/'); mode=e.external_attr>>16; kind=stat.S_IFMT(mode)
|
|
89
|
+
if not parts or parts[0] not in ['agent','META-INF'] or '\\' in n or any(x in ['','.','..'] or any(ord(c)<32 for c in x) for x in parts) or kind not in [0,stat.S_IFDIR,stat.S_IFREG]: fail()
|
|
90
|
+
if n.rstrip('/') in seen: fail()
|
|
91
|
+
seen.add(n.rstrip('/')); expanded+=e.file_size
|
|
92
|
+
if expanded>2*1024**3: fail()
|
|
93
|
+
for e in entries:
|
|
94
|
+
dest=os.path.join(d,e.filename)
|
|
95
|
+
if e.is_dir(): os.makedirs(dest,mode=0o700,exist_ok=True); continue
|
|
96
|
+
os.makedirs(os.path.dirname(dest),mode=0o700,exist_ok=True)
|
|
97
|
+
with z.open(e) as source,open(dest,'xb') as target: shutil.copyfileobj(source,target,1024*1024)
|
|
98
|
+
os.chmod(dest,0o700 if (e.external_attr>>16)&0o111 else 0o600)
|
|
99
|
+
agent=os.path.join(d,'agent')
|
|
100
|
+
for name in ['config.xml','conf/wrapper.conf','conf/wrapper-override.conf','bin/mid.sh','bin/wrapper-linux-x86-64','jre/bin/java']:
|
|
101
|
+
if not os.path.isfile(os.path.join(agent,name)): fail()
|
|
102
|
+
wrapper=open(os.path.join(agent,'conf/wrapper.conf')).read()
|
|
103
|
+
script=open(os.path.join(agent,'bin/mid.sh')).read()
|
|
104
|
+
if 'wrapper.java.command=jre/bin/java' not in wrapper or 'wrapper-override.conf' not in wrapper or 'SERVICE_MANAGEMENT_TOOL' not in script: fail()
|
|
105
|
+
config=os.path.join(agent,'config.xml'); tree=ET.parse(config,parser=ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))); root=tree.getroot()
|
|
106
|
+
if root.tag!='parameters': fail()
|
|
107
|
+
for key,value in {'url':p['instanceURL']+'/','name':p['name'],'mid.instance.username':p['username'],'mid.instance.password':p['password']}.items():
|
|
108
|
+
nodes=[e for e in root.findall('parameter') if e.get('name')==key]
|
|
109
|
+
if len(nodes)>1: fail()
|
|
110
|
+
e=nodes[0] if nodes else ET.SubElement(root,'parameter',{'name':key}); e.set('value',value)
|
|
111
|
+
if key=='mid.instance.password': e.set('secure','true')
|
|
112
|
+
tree.write(config,encoding='utf-8',xml_declaration=True)
|
|
113
|
+
token='jsn_mid_'+p['owner'].replace('-','')
|
|
114
|
+
with open(os.path.join(agent,'conf/wrapper-override.conf'),'a') as f: f.write('\nwrapper.name='+token+'\nwrapper.displayname=ServiceNow MID '+token+'\n')
|
|
115
|
+
with open(os.path.join(agent,'bin/mid.shconf_override'),'a') as f: f.write('\nAPP_NAME="'+token+'"\nAPP_LONG_NAME="ServiceNow MID '+token+'"\nSERVICE_MANAGEMENT_TOOL=initd\n')
|
|
116
|
+
os.unlink(archive); result(dict(installed=True,archiveSha256=digest.hexdigest(),status='stopped'))
|
|
117
|
+
else:
|
|
118
|
+
owned()
|
|
119
|
+
if action=='start':
|
|
120
|
+
if processes(): fail()
|
|
121
|
+
launcher('start'); time.sleep(2)
|
|
122
|
+
if not processes(): fail()
|
|
123
|
+
elif action=='stop':
|
|
124
|
+
if processes(): launcher('stop')
|
|
125
|
+
for attempt in range(60):
|
|
126
|
+
if not processes(): break
|
|
127
|
+
time.sleep(1)
|
|
128
|
+
if processes(): fail()
|
|
129
|
+
elif action=='remove':
|
|
130
|
+
if processes(): fail()
|
|
131
|
+
safe_tree(); shutil.rmtree(d); result(dict(removed=True)); sys.exit(0)
|
|
132
|
+
elif action=='logs':
|
|
133
|
+
safe_tree()
|
|
134
|
+
directory=os.path.join(d,'agent','logs'); candidates=[]
|
|
135
|
+
if os.path.isdir(directory):
|
|
136
|
+
for n in os.listdir(directory):
|
|
137
|
+
if re.fullmatch(r'agent0\.log(?:\.[0-9]+)?',n) or n=='wrapper.log':
|
|
138
|
+
f=os.path.join(directory,n); s=os.lstat(f)
|
|
139
|
+
if stat.S_ISREG(s.st_mode) and s.st_uid==os.getuid(): candidates.append((n!='wrapper.log',s.st_mtime,f))
|
|
140
|
+
text=''
|
|
141
|
+
if candidates:
|
|
142
|
+
with open(max(candidates)[2],'rb') as f:
|
|
143
|
+
f.seek(max(0,os.fstat(f.fileno()).st_size-1024*1024)); text=f.read().decode('utf-8','replace')
|
|
144
|
+
result(dict(logs='\n'.join(text.splitlines()[-p['tail']:]))); sys.exit(0)
|
|
145
|
+
elif action!='inspect': fail()
|
|
146
|
+
ids=processes(); result(dict(status='running' if ids else 'stopped',pids=ids,runtime='user-background',requiresElevation=False))
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
function remote(target, payload, { run = execFileSync, timeout = 30000 } = {}) {
|
|
150
|
+
validateRemoteTarget(target);
|
|
151
|
+
const encoded = Buffer.from(PROGRAM).toString('base64');
|
|
152
|
+
const command = `python3 -c 'import base64;exec(base64.b64decode("${encoded}"))'`;
|
|
153
|
+
let output;
|
|
154
|
+
try { output = String(run('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'RemoteCommand=none', '-o', 'RequestTTY=no', '--', target, command], { input: JSON.stringify(payload), timeout, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] })); }
|
|
155
|
+
catch { throw new Error('Remote Linux MID operation failed; Python 3 and a non-root Linux x64 account are required. Inspect owned installation before retrying.'); }
|
|
156
|
+
const line = output.split(/\r?\n/).findLast(value => value.startsWith('JSN_RESULT:'));
|
|
157
|
+
try { if (!line) throw new Error(); return JSON.parse(line.slice(11)); }
|
|
158
|
+
catch { throw new Error('Remote Linux MID returned no verified result'); }
|
|
159
|
+
}
|
|
160
|
+
function save(directory, m) { fs.writeFileSync(path.join(directory, FILE), JSON.stringify(m, null, 2), { mode: 0o600 }); fs.chmodSync(path.join(directory, FILE), 0o600); }
|
|
161
|
+
function load(directory) {
|
|
162
|
+
const dir = fs.lstatSync(directory), file = fs.lstatSync(path.join(directory, FILE));
|
|
163
|
+
if (!dir.isDirectory() || dir.isSymbolicLink() || !file.isFile() || file.isSymbolicLink() || (dir.mode & 0o077) || (file.mode & 0o077)) throw new Error('Linux MID metadata must be private');
|
|
164
|
+
const m = JSON.parse(fs.readFileSync(path.join(directory, FILE), 'utf8')); validateRemoteTarget(m.target);
|
|
165
|
+
if (m.version !== 1 || !/^[a-f0-9-]{36}$/.test(m.owner) || !m.remoteDirectory?.startsWith('/') || m.remoteDirectory !== path.posix.normalize(m.remoteDirectory) || !m.remoteDirectory.endsWith(`/.jsn-mid/${m.owner}`)) throw new Error('Invalid Linux MID ownership metadata');
|
|
166
|
+
return m;
|
|
167
|
+
}
|
|
168
|
+
export function prepareLinuxRemoteMidServer({ target, instanceURL, name, directory, platform = process.platform, ...options }) {
|
|
169
|
+
if (platform === 'win32') throw new Error('A POSIX controller is required for private local metadata');
|
|
170
|
+
validateRemoteTarget(target);
|
|
171
|
+
const url = new URL(instanceURL);
|
|
172
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || '')) throw new Error('Valid HTTPS origin and MID name required');
|
|
173
|
+
if (fs.existsSync(directory)) throw new Error('Local deployment directory exists; no adoption');
|
|
174
|
+
const owner = randomUUID();
|
|
175
|
+
const report = remote(target, { action: 'prepare', owner }, options);
|
|
176
|
+
if (report.platform !== 'linux-x64' || report.requiresElevation !== false || report.freeBytes < 3 * 1024 ** 3 || !report.remoteDirectory?.endsWith(`/.jsn-mid/${owner}`)) throw new Error('Linux preflight failed');
|
|
177
|
+
const m = { version: 1, target, owner, instanceURL: url.origin, name, ...report, state: 'prepared' };
|
|
178
|
+
fs.mkdirSync(directory, { mode: 0o700 }); fs.chmodSync(directory, 0o700); save(directory, m); return m;
|
|
179
|
+
}
|
|
180
|
+
export function installLinuxRemoteMidServer({ directory, releaseName, username, password, ...options }) {
|
|
181
|
+
const m = load(directory);
|
|
182
|
+
if (m.state !== 'prepared' || !username || !password || [...String(username) + String(password)].some(char => char.codePointAt(0) < 32 && !['\t', '\n', '\r'].includes(char))) throw new Error('Fresh deployment and valid credentials required');
|
|
183
|
+
const archiveURL = buildMidZipUrl(releaseName, 'linux');
|
|
184
|
+
m.state = 'installing'; save(directory, m);
|
|
185
|
+
try {
|
|
186
|
+
const result = remote(m.target, { ...m, action: 'install', archiveURL, username, password }, { ...options, timeout: 1200000 });
|
|
187
|
+
if (!result.installed || !/^[a-f0-9]{64}$/i.test(result.archiveSha256 || '')) throw new Error('Installation unverified');
|
|
188
|
+
Object.assign(m, { state: 'installed', releaseName, archiveSha256: result.archiveSha256 }); save(directory, m); return m;
|
|
189
|
+
} catch (error) { m.state = 'uncertain'; save(directory, m); throw error; }
|
|
190
|
+
}
|
|
191
|
+
function operation(action, { directory, ...options }) {
|
|
192
|
+
const m = load(directory);
|
|
193
|
+
try {
|
|
194
|
+
const result = remote(m.target, { ...m, action }, { ...options, timeout: action === 'start' || action === 'stop' ? 180000 : 30000 });
|
|
195
|
+
if ((action === 'start' && result.status !== 'running') || (action === 'stop' && result.status !== 'stopped')) throw new Error('Runtime state unverified');
|
|
196
|
+
if (action === 'remove' && result.removed !== true) throw new Error('Removal unverified');
|
|
197
|
+
m.state = action === 'remove' ? 'removed' : result.status; save(directory, m); return { ...m, ...result };
|
|
198
|
+
} catch (error) { m.state = 'uncertain'; save(directory, m); throw error; }
|
|
199
|
+
}
|
|
200
|
+
export const inspectLinuxRemoteMidServer = options => operation('inspect', options);
|
|
201
|
+
export const startLinuxRemoteMidServer = options => operation('start', options);
|
|
202
|
+
export const stopLinuxRemoteMidServer = options => operation('stop', options);
|
|
203
|
+
export function removeLinuxRemoteMidServer(options) {
|
|
204
|
+
stopLinuxRemoteMidServer(options);
|
|
205
|
+
return operation('remove', options);
|
|
206
|
+
}
|
|
207
|
+
export function logsLinuxRemoteMidServer({ directory, tail = 100, ...options }) {
|
|
208
|
+
if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be 1–10000');
|
|
209
|
+
const m = load(directory); return remote(m.target, { ...m, action: 'logs', tail }, options).logs;
|
|
210
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { buildMidRecipeUrl } from './midserver-install.js';
|
|
5
|
+
import { inspectMidZip, extractMidZip } from './midserver-zip.js';
|
|
6
|
+
|
|
7
|
+
const LIMIT = 64 * 1024 * 1024;
|
|
8
|
+
export function recipeTar(directory) {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
let total = 0;
|
|
11
|
+
function add(folder, prefix = '') {
|
|
12
|
+
for (const name of fs.readdirSync(folder).sort()) {
|
|
13
|
+
const filename = path.join(folder, name), relative = prefix + name, stat = fs.lstatSync(filename);
|
|
14
|
+
if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) throw new Error('Recipe contains a link or special file');
|
|
15
|
+
if (stat.isDirectory()) { add(filename, `${relative}/`); continue; }
|
|
16
|
+
const split = relative.lastIndexOf('/');
|
|
17
|
+
const leaf = Buffer.byteLength(relative) <= 100 ? relative : relative.slice(split + 1);
|
|
18
|
+
const parent = Buffer.byteLength(relative) <= 100 ? '' : relative.slice(0, split);
|
|
19
|
+
if (Buffer.byteLength(leaf) > 100 || Buffer.byteLength(parent) > 155 || split < 0 && Buffer.byteLength(relative) > 100) throw new Error('Recipe path exceeds tar limits');
|
|
20
|
+
total += stat.size; if (total > LIMIT) throw new Error('MID recipe exceeds 64 MiB');
|
|
21
|
+
const header = Buffer.alloc(512);
|
|
22
|
+
const write = (value, offset, length) => header.write(String(value), offset, length, 'utf8');
|
|
23
|
+
const octal = (value, offset, length) => write(value.toString(8).padStart(length - 1, '0') + '\0', offset, length);
|
|
24
|
+
write(leaf, 0, 100); octal(stat.mode & 0o111 ? 0o755 : 0o644, 100, 8); octal(0, 108, 8); octal(0, 116, 8);
|
|
25
|
+
octal(stat.size, 124, 12); octal(0, 136, 12); header.fill(32, 148, 156); write('0', 156, 1); write('ustar\0', 257, 6); write('00', 263, 2); write(parent, 345, 155);
|
|
26
|
+
const checksum = header.reduce((sum, byte) => sum + byte, 0); write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 8);
|
|
27
|
+
chunks.push(header, fs.readFileSync(filename), Buffer.alloc((512 - stat.size % 512) % 512));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
add(directory); chunks.push(Buffer.alloc(1024)); return Buffer.concat(chunks);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function downloadRemoteMidRecipe(releaseName, { fetchImpl = fetch } = {}) {
|
|
34
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'jsn-mid-recipe-'));
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetchImpl(buildMidRecipeUrl(releaseName), { redirect: 'error', signal: AbortSignal.timeout(300000) });
|
|
37
|
+
if (!response.ok || !response.body) throw new Error(`MID recipe download failed (HTTP ${response.status})`);
|
|
38
|
+
const archive = path.join(directory, 'recipe.zip'), fd = fs.openSync(archive, 'wx', 0o600); let bytes = 0;
|
|
39
|
+
try { for await (const chunk of response.body) { bytes += chunk.length; if (bytes > LIMIT) throw new Error('MID recipe download exceeds 64 MiB'); fs.writeSync(fd, chunk); } } finally { fs.closeSync(fd); }
|
|
40
|
+
const entries = inspectMidZip(archive, { allowedRoots: null });
|
|
41
|
+
if (entries.reduce((sum, entry) => sum + entry.uncompressedSize, 0) > LIMIT) throw new Error('Expanded MID recipe exceeds 64 MiB');
|
|
42
|
+
const recipe = path.join(directory, 'recipe'); await extractMidZip(archive, recipe, entries);
|
|
43
|
+
// Distribution archives can wrap the Dockerfile in one enclosing directory.
|
|
44
|
+
const candidates = [];
|
|
45
|
+
function find(folder) { for (const name of fs.readdirSync(folder)) { const file = path.join(folder, name); if (fs.statSync(file).isDirectory()) find(file); else if (name === 'Dockerfile') candidates.push(path.dirname(file)); } }
|
|
46
|
+
find(recipe); if (candidates.length !== 1) throw new Error('Official recipe must contain exactly one Dockerfile');
|
|
47
|
+
return recipeTar(candidates[0]);
|
|
48
|
+
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
|
49
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
|
|
7
|
+
const FILE = '.jsn-mid-remote.json';
|
|
8
|
+
const PREFIX = 'io.jsn.mid';
|
|
9
|
+
const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'RemoteCommand=none', '-o', 'RequestTTY=no', '--'];
|
|
10
|
+
|
|
11
|
+
export function validateRemoteTarget(target) {
|
|
12
|
+
if (typeof target !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/.test(target)) throw new Error('Use a configured SSH alias containing only letters, numbers, dots, underscores or dashes');
|
|
13
|
+
return target;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function defaultRun(command, args, options) {
|
|
17
|
+
const { mergeStderr, ...settings } = options;
|
|
18
|
+
const execution = { ...settings, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 4 * 1024 * 1024 };
|
|
19
|
+
if (!mergeStderr) return execFileSync(command, args, execution);
|
|
20
|
+
const result = spawnSync(command, args, execution);
|
|
21
|
+
if (result.error || result.status !== 0) throw new Error('Remote log snapshot failed');
|
|
22
|
+
return `${result.stdout || ''}${result.stderr || ''}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sshArgs(target, args) {
|
|
26
|
+
validateRemoteTarget(target);
|
|
27
|
+
// OpenSSH joins the command for the remote shell. Every argument is constrained,
|
|
28
|
+
// including format expressions; secrets and manifests travel only on stdin.
|
|
29
|
+
if (args.some(arg => typeof arg !== 'string' || !/^[A-Za-z0-9_./:{}=,@+-]+$/.test(arg))) throw new Error('Unsafe remote Docker argument');
|
|
30
|
+
return [...SSH_OPTIONS, target, `docker ${args.join(' ')}`];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function docker(target, args, { run = defaultRun, input, timeout = 30000, signal, mergeStderr = false } = {}) {
|
|
34
|
+
const commandArgs = sshArgs(target, args);
|
|
35
|
+
try { return String(run('ssh', commandArgs, { input, timeout, signal, mergeStderr }) || '').trim(); }
|
|
36
|
+
catch { throw new Error(`Remote Docker ${args[0]} failed; inspect the selected target and retained local metadata`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function probe(target, options) {
|
|
40
|
+
const platform = docker(target, ['info', '--format', '{{.OSType}}/{{.Architecture}}'], options);
|
|
41
|
+
if (!/^linux\/(amd64|x86_64)$/.test(platform)) throw new Error('Remote MID containers require a Linux amd64 Docker engine');
|
|
42
|
+
docker(target, ['compose', 'version'], options);
|
|
43
|
+
const engineId = docker(target, ['info', '--format', '{{.ID}}'], options);
|
|
44
|
+
if (!engineId) throw new Error('Remote Docker did not report an engine ID');
|
|
45
|
+
return engineId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function save(directory, metadata) {
|
|
49
|
+
fs.writeFileSync(path.join(directory, FILE), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
50
|
+
fs.chmodSync(path.join(directory, FILE), 0o600);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function load(directory) {
|
|
54
|
+
const dir = fs.lstatSync(directory);
|
|
55
|
+
const file = fs.lstatSync(path.join(directory, FILE));
|
|
56
|
+
if (!dir.isDirectory() || dir.isSymbolicLink() || !file.isFile() || file.isSymbolicLink() || (dir.mode & 0o077) || (file.mode & 0o077)) throw new Error('Remote MID metadata directory and file must be private and must not be symlinks');
|
|
57
|
+
const metadata = JSON.parse(fs.readFileSync(path.join(directory, FILE), 'utf8'));
|
|
58
|
+
validateRemoteTarget(metadata.target);
|
|
59
|
+
if (metadata.version !== 1 || !/^[a-f0-9-]{36}$/.test(metadata.owner)) throw new Error('Invalid remote MID ownership metadata');
|
|
60
|
+
for (const key of ['project', 'container', 'volume', 'image', ...(metadata.network ? ['network'] : [])]) if (!/^[a-z0-9][a-z0-9_.:-]+$/.test(metadata[key])) throw new Error('Invalid remote MID resource metadata');
|
|
61
|
+
return metadata;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function labels(metadata) {
|
|
65
|
+
return { [`${PREFIX}.owner`]: metadata.owner, [`${PREFIX}.instance`]: metadata.instanceURL, [`${PREFIX}.target`]: metadata.target, [`${PREFIX}.name`]: metadata.name };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkEngine(metadata, options) {
|
|
69
|
+
if (probe(metadata.target, options) !== metadata.engineId) throw new Error('SSH target now selects a different Docker engine; refusing lifecycle operation');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function inspect(metadata, kind, resource, options) {
|
|
73
|
+
let value;
|
|
74
|
+
try { value = JSON.parse(docker(metadata.target, [kind, 'inspect', resource], options))[0]; } catch { throw new Error(`Cannot verify owned remote ${kind}; no destructive action taken`); }
|
|
75
|
+
const actual = value?.Config?.Labels || value?.Labels;
|
|
76
|
+
if (!actual || Object.entries(labels(metadata)).some(([key, expected]) => actual[key] !== expected)) throw new Error(`Remote ${kind} ownership mismatch; refusing operation`);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function prepareRemoteMidServer({ target, instanceURL, name, directory, platform = process.platform, ...options }) {
|
|
81
|
+
if (platform === 'win32') throw new Error('Remote MID management requires a POSIX controller to enforce private local metadata permissions; Windows Docker hosts remain supported over SSH');
|
|
82
|
+
validateRemoteTarget(target);
|
|
83
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || '')) throw new Error('Invalid MID Server name');
|
|
84
|
+
const url = new URL(instanceURL);
|
|
85
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('Use an HTTPS instance origin without credentials');
|
|
86
|
+
if (fs.existsSync(directory)) throw new Error('Remote MID local directory already exists; refusing to overwrite or adopt');
|
|
87
|
+
const owner = randomUUID();
|
|
88
|
+
const project = `jsn-mid-${owner}`;
|
|
89
|
+
const metadata = { version: 1, target, instanceURL: url.origin, name, owner, project, container: project, image: `${project}:local`, volume: `${project}-export`, network: `${project}-network`, engineId: probe(target, options), state: 'prepared' };
|
|
90
|
+
fs.mkdirSync(directory, { recursive: false, mode: 0o700 });
|
|
91
|
+
fs.chmodSync(directory, 0o700);
|
|
92
|
+
save(directory, metadata);
|
|
93
|
+
return metadata;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildRemoteMidManifest(metadata, { username, password }) {
|
|
97
|
+
if (!username || !password) throw new Error('MID username and password are required');
|
|
98
|
+
const ownership = labels(metadata);
|
|
99
|
+
const literal = value => String(value).replaceAll('$', () => '$$');
|
|
100
|
+
return { services: { mid: { container_name: metadata.container, image: metadata.image, pull_policy: 'never', restart: 'unless-stopped', labels: ownership, environment: { MID_INSTANCE_URL: `${metadata.instanceURL}/`, MID_INSTANCE_USERNAME: literal(username), MID_INSTANCE_PASSWORD: literal(password), MID_SERVER_NAME: literal(metadata.name) }, volumes: [`${metadata.volume}:/opt/snc_mid_server/agent/export`], networks: ['mid'] } }, volumes: { [metadata.volume]: { name: metadata.volume, labels: ownership } }, networks: { mid: { name: metadata.network, labels: ownership } } };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resources(metadata, options) {
|
|
104
|
+
const checks = [ ['container', ['container', 'ls', '-a', '--filter', `name=${metadata.container}`, '--format', '{{.Names}}'], metadata.container], ['volume', ['volume', 'ls', '--filter', `name=${metadata.volume}`, '--format', '{{.Name}}'], metadata.volume], ['image', ['image', 'ls', '--filter', `reference=${metadata.image}`, '--format', '{{.Repository}}:{{.Tag}}'], metadata.image] ];
|
|
105
|
+
if (metadata.network) checks.push(['network', ['network', 'ls', '--filter', `name=${metadata.network}`, '--format', '{{.Name}}'], metadata.network]);
|
|
106
|
+
return checks.filter(([, args, resource]) => docker(metadata.target, args, options).split(/\r?\n/).includes(resource)).map(([kind]) => kind);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function absent(metadata, options) {
|
|
110
|
+
if (resources(metadata, options).length) throw new Error('Remote resources already exist; refusing to overwrite or adopt');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function installRemoteMidServer({ directory, context, username, password, ...options }) {
|
|
114
|
+
const metadata = load(directory);
|
|
115
|
+
if (metadata.state !== 'prepared') throw new Error('Install requires a fresh prepared deployment; retained partial deployments must be inspected and removed explicitly');
|
|
116
|
+
if (!Buffer.isBuffer(context) || context.length === 0) throw new Error('An official recipe tar build context is required');
|
|
117
|
+
const manifest = buildRemoteMidManifest(metadata, { username, password });
|
|
118
|
+
checkEngine(metadata, options);
|
|
119
|
+
absent(metadata, options);
|
|
120
|
+
metadata.state = 'building'; save(directory, metadata);
|
|
121
|
+
try {
|
|
122
|
+
const buildLabels = Object.entries(labels(metadata)).flatMap(([key, value]) => ['--label', `${key}=${value}`]);
|
|
123
|
+
docker(metadata.target, ['build', ...buildLabels, '--tag', metadata.image, '-'], { ...options, input: context, timeout: 1200000 });
|
|
124
|
+
inspect(metadata, 'image', metadata.image, options);
|
|
125
|
+
metadata.state = 'deploying'; save(directory, metadata);
|
|
126
|
+
docker(metadata.target, ['compose', '-p', metadata.project, '-f', '-', 'up', '-d'], { ...options, input: JSON.stringify(manifest), timeout: 300000 });
|
|
127
|
+
inspect(metadata, 'container', metadata.container, options);
|
|
128
|
+
inspect(metadata, 'volume', metadata.volume, options);
|
|
129
|
+
inspect(metadata, 'network', metadata.network, options);
|
|
130
|
+
metadata.state = 'running'; save(directory, metadata);
|
|
131
|
+
return metadata;
|
|
132
|
+
} catch (error) { metadata.state = 'uncertain'; save(directory, metadata); throw error; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function inspectRemoteMidServer({ directory, ...options }) {
|
|
136
|
+
const metadata = load(directory); checkEngine(metadata, options);
|
|
137
|
+
const container = inspect(metadata, 'container', metadata.container, options);
|
|
138
|
+
return { ...metadata, status: container.State?.Status || 'unknown' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function lifecycle(operation, { directory, ...options }) {
|
|
142
|
+
const metadata = load(directory); checkEngine(metadata, options);
|
|
143
|
+
inspect(metadata, 'container', metadata.container, options);
|
|
144
|
+
try { docker(metadata.target, ['container', operation, metadata.container], options); }
|
|
145
|
+
catch (error) { metadata.state = 'uncertain'; save(directory, metadata); throw error; }
|
|
146
|
+
metadata.state = operation === 'start' ? 'running' : 'stopped'; save(directory, metadata);
|
|
147
|
+
return metadata;
|
|
148
|
+
}
|
|
149
|
+
export const startRemoteMidServer = options => lifecycle('start', options);
|
|
150
|
+
export const stopRemoteMidServer = options => lifecycle('stop', options);
|
|
151
|
+
|
|
152
|
+
export function logsRemoteMidServer({ directory, tail = 100, ...options }) {
|
|
153
|
+
if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be between 1 and 10000');
|
|
154
|
+
const metadata = load(directory); checkEngine(metadata, options);
|
|
155
|
+
inspect(metadata, 'container', metadata.container, options);
|
|
156
|
+
return docker(metadata.target, ['container', 'logs', '--tail', String(tail), metadata.container], { ...options, mergeStderr: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function followRemoteMidServer({ directory, tail = 100, spawnImpl = spawn, stdout = process.stdout, stderr = process.stderr, signalSource = process, ...options }) {
|
|
160
|
+
if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be between 1 and 10000');
|
|
161
|
+
const metadata = load(directory); checkEngine(metadata, options);
|
|
162
|
+
inspect(metadata, 'container', metadata.container, options);
|
|
163
|
+
const args = sshArgs(metadata.target, ['container', 'logs', '--follow', '--tail', String(tail), metadata.container]);
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
let child;
|
|
166
|
+
let cancelled = false;
|
|
167
|
+
let timer;
|
|
168
|
+
let settled = false;
|
|
169
|
+
const cleanup = () => {
|
|
170
|
+
signalSource.removeListener('SIGINT', cancel);
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
child?.stdout?.unpipe(stdout);
|
|
173
|
+
child?.stderr?.unpipe(stderr);
|
|
174
|
+
child?.removeListener('error', failed);
|
|
175
|
+
child?.removeListener('close', closed);
|
|
176
|
+
};
|
|
177
|
+
const finish = error => {
|
|
178
|
+
if (settled) return;
|
|
179
|
+
settled = true; cleanup();
|
|
180
|
+
if (error) reject(new Error('Remote Docker log viewer failed; the container was not stopped'));
|
|
181
|
+
else resolve({ cancelled });
|
|
182
|
+
};
|
|
183
|
+
const failed = () => finish(true);
|
|
184
|
+
const closed = code => finish(!cancelled && code !== 0);
|
|
185
|
+
const cancel = () => {
|
|
186
|
+
if (cancelled || settled) return;
|
|
187
|
+
cancelled = true;
|
|
188
|
+
child.kill('SIGINT');
|
|
189
|
+
timer = setTimeout(() => { child.kill('SIGKILL'); finish(false); }, 2000);
|
|
190
|
+
timer.unref?.();
|
|
191
|
+
};
|
|
192
|
+
try {
|
|
193
|
+
child = spawnImpl('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
194
|
+
child.on('error', failed);
|
|
195
|
+
child.on('close', closed);
|
|
196
|
+
child.stdout?.pipe(stdout, { end: false });
|
|
197
|
+
child.stderr?.pipe(stderr, { end: false });
|
|
198
|
+
signalSource.on('SIGINT', cancel);
|
|
199
|
+
} catch { finish(true); }
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function removeRemoteMidServer({ directory, ...options }) {
|
|
204
|
+
const metadata = load(directory); checkEngine(metadata, options);
|
|
205
|
+
// A partial install may have only an image or volume. Verify every present
|
|
206
|
+
// resource before deleting any, and preserve metadata for retries/diagnosis.
|
|
207
|
+
const present = resources(metadata, options);
|
|
208
|
+
for (const kind of present) inspect(metadata, kind, metadata[kind], options);
|
|
209
|
+
metadata.state = 'removing'; save(directory, metadata);
|
|
210
|
+
try {
|
|
211
|
+
if (present.includes('container')) {
|
|
212
|
+
docker(metadata.target, ['container', 'stop', metadata.container], options);
|
|
213
|
+
docker(metadata.target, ['container', 'rm', metadata.container], options);
|
|
214
|
+
}
|
|
215
|
+
if (present.includes('volume')) docker(metadata.target, ['volume', 'rm', metadata.volume], options);
|
|
216
|
+
if (present.includes('network')) docker(metadata.target, ['network', 'rm', metadata.network], options);
|
|
217
|
+
if (present.includes('image')) docker(metadata.target, ['image', 'rm', metadata.image], options);
|
|
218
|
+
} catch (error) { metadata.state = 'uncertain'; save(directory, metadata); throw error; }
|
|
219
|
+
metadata.state = 'removed'; save(directory, metadata);
|
|
220
|
+
return metadata;
|
|
221
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { validateRemoteTarget } from './midserver-remote.js';
|
|
7
|
+
import { buildMidZipUrl } from './midserver-direct.js';
|
|
8
|
+
|
|
9
|
+
const FILE = '.jsn-mid-windows-remote.json';
|
|
10
|
+
const SSH = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'RemoteCommand=none', '-o', 'RequestTTY=no', '--'];
|
|
11
|
+
const PHASES = new Set(['initialization', 'validate', 'reserve-directory', 'protect-directory', 'download', 'verify-archive', 'inspect-archive', 'extract', 'vendor-contract', 'configure', 'register-service', 'verify-service', 'enforce-permissions', 'remove-files', 'recover-file-acl', 'stop-owned-processes']);
|
|
12
|
+
const HEADER = `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; $phase='initialization';
|
|
13
|
+
trap { [Console]::Error.WriteLine(('JSN_PHASE_FAILED:' + $phase)); exit 1 }
|
|
14
|
+
$p=([Console]::In.ReadToEnd() | ConvertFrom-Json)
|
|
15
|
+
function Result($v) { [Console]::Out.WriteLine(('JSN_RESULT:' + ($v | ConvertTo-Json -Compress -Depth 8))) }
|
|
16
|
+
function Assert-Admin { if ($env:OS -ne 'Windows_NT') { throw 'Windows required' }; $i=[Security.Principal.WindowsIdentity]::GetCurrent(); $principal=New-Object Security.Principal.WindowsPrincipal($i); if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Elevated service installation rights required' } }
|
|
17
|
+
function Assert-Owned {
|
|
18
|
+
Assert-Admin
|
|
19
|
+
if (!(Test-Path -LiteralPath $p.remoteDirectory -PathType Container)) { throw 'Owned directory missing' }
|
|
20
|
+
$item=Get-Item -LiteralPath $p.remoteDirectory
|
|
21
|
+
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse point forbidden' }
|
|
22
|
+
$marker=Get-Content -Raw -LiteralPath (Join-Path $p.remoteDirectory '.jsn-owner.json') | ConvertFrom-Json
|
|
23
|
+
if ($marker.owner -ne $p.owner -or $marker.serviceName -ne $p.serviceName -or $marker.instanceURL -ne $p.instanceURL -or $marker.name -ne $p.name) { throw 'Ownership mismatch' }
|
|
24
|
+
}
|
|
25
|
+
function Owned-Service {
|
|
26
|
+
$s=Get-CimInstance Win32_Service -Filter ('Name="' + $p.serviceName + '"')
|
|
27
|
+
if ($s) {
|
|
28
|
+
$expected=[IO.Path]::GetFullPath((Join-Path $p.remoteDirectory 'agent\\bin\\wrapper-windows-x86-64.exe'))
|
|
29
|
+
$command=[regex]::Match($s.PathName,'^\\s*(?:"([^"\\r\\n]+)"|(.+?\\.exe))(?:\\s|$)',[Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
|
30
|
+
if (!$command.Success) { throw 'Service binary ownership mismatch' }
|
|
31
|
+
$binary=$command.Groups[1].Value; if (!$binary) { $binary=$command.Groups[2].Value }
|
|
32
|
+
if ([IO.Path]::GetFullPath($binary) -ne $expected) { throw 'Service binary ownership mismatch' }
|
|
33
|
+
if ($s.StartName -ne 'NT AUTHORITY\\LocalService' -and $s.StartName -ne 'LocalService') { throw 'Unexpected runtime service account' }
|
|
34
|
+
}
|
|
35
|
+
return $s
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
function remote(target, script, payload, { run = execFileSync, timeout = 30000 } = {}) {
|
|
40
|
+
validateRemoteTarget(target);
|
|
41
|
+
const bootstrap = `$ErrorActionPreference='Stop'; try { [Console]::InputEncoding=New-Object Text.UTF8Encoding($false); [Console]::OutputEncoding=New-Object Text.UTF8Encoding($false); $source=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadLine())); & ([ScriptBlock]::Create($source)) } catch { [Console]::Error.WriteLine('Remote MID bootstrap failed'); exit 1 }`;
|
|
42
|
+
const encoded = Buffer.from(bootstrap, 'utf16le').toString('base64');
|
|
43
|
+
const input = `${Buffer.from(HEADER + script, 'utf8').toString('base64')}\n${JSON.stringify(payload)}`;
|
|
44
|
+
let output;
|
|
45
|
+
try { output = String(run('ssh', [...SSH, target, `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`], { input, timeout, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] })); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
const candidate = String(error.stderr || '').split(/\r?\n/).findLast(line => /^JSN_PHASE_FAILED:[a-z-]+$/.test(line));
|
|
48
|
+
const phase = candidate?.slice('JSN_PHASE_FAILED:'.length);
|
|
49
|
+
const failure = new Error(`Remote Windows MID operation failed${PHASES.has(phase) ? ` during ${phase}` : ''}; inspect retained ownership metadata and service status`);
|
|
50
|
+
if (PHASES.has(phase)) failure.phase = phase;
|
|
51
|
+
throw failure;
|
|
52
|
+
}
|
|
53
|
+
const line = output.split(/\r?\n/).findLast(value => value.startsWith('JSN_RESULT:'));
|
|
54
|
+
if (!line) throw new Error('Remote Windows MID returned no verified result');
|
|
55
|
+
try { return JSON.parse(line.slice('JSN_RESULT:'.length)); } catch { throw new Error('Invalid remote Windows MID result'); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function save(directory, metadata) {
|
|
59
|
+
fs.writeFileSync(path.join(directory, FILE), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
60
|
+
fs.chmodSync(path.join(directory, FILE), 0o600);
|
|
61
|
+
}
|
|
62
|
+
function load(directory) {
|
|
63
|
+
const dir = fs.lstatSync(directory); const file = fs.lstatSync(path.join(directory, FILE));
|
|
64
|
+
if (!dir.isDirectory() || dir.isSymbolicLink() || !file.isFile() || file.isSymbolicLink() || (dir.mode & 0o077) || (file.mode & 0o077)) throw new Error('Windows MID local metadata must be private');
|
|
65
|
+
const m = JSON.parse(fs.readFileSync(path.join(directory, FILE), 'utf8'));
|
|
66
|
+
validateRemoteTarget(m.target);
|
|
67
|
+
if (m.version !== 1 || !/^[a-f0-9-]{36}$/.test(m.owner) || m.serviceName !== `jsn-mid-${m.owner}` || !/^[A-Z]:\\[A-Za-z0-9 _.-]+(?:\\[A-Za-z0-9 _.-]+)*$/i.test(m.remoteDirectory) || !m.remoteDirectory.endsWith(`\\${m.owner}`)) throw new Error('Invalid Windows MID ownership metadata');
|
|
68
|
+
return m;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function prepareWindowsRemoteMidServer({ target, instanceURL, name, directory, platform = process.platform, ...options }) {
|
|
72
|
+
if (platform === 'win32') throw new Error('A POSIX controller is required for private local metadata');
|
|
73
|
+
validateRemoteTarget(target);
|
|
74
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || '')) throw new Error('Invalid MID name');
|
|
75
|
+
const url = new URL(instanceURL);
|
|
76
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Use an HTTPS instance origin');
|
|
77
|
+
if (fs.existsSync(directory)) throw new Error('Local deployment directory already exists; no adoption or overwrite');
|
|
78
|
+
const owner = randomUUID(); const serviceName = `jsn-mid-${owner}`;
|
|
79
|
+
const probe = remote(target, `Assert-Admin
|
|
80
|
+
if ([Environment]::Is64BitOperatingSystem -ne $true) { throw 'x64 Windows required' }
|
|
81
|
+
$base=Join-Path $env:ProgramData 'jsn-mid'
|
|
82
|
+
$remoteDirectory=Join-Path $base $p.owner
|
|
83
|
+
if (Test-Path -LiteralPath $remoteDirectory) { throw 'Deployment directory exists' }
|
|
84
|
+
if (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { throw 'Service already exists' }
|
|
85
|
+
Result @{remoteDirectory=$remoteDirectory; elevated=$true; platform='windows-x64'}`, { owner, serviceName }, options);
|
|
86
|
+
if (!probe.elevated || probe.platform !== 'windows-x64' || !probe.remoteDirectory?.endsWith(`\\${owner}`)) throw new Error('Windows preflight failed');
|
|
87
|
+
const metadata = { version: 1, target, owner, serviceName, instanceURL: url.origin, name, remoteDirectory: probe.remoteDirectory, state: 'prepared', runtimeAccount: 'NT AUTHORITY\\LocalService' };
|
|
88
|
+
fs.mkdirSync(directory, { mode: 0o700 }); fs.chmodSync(directory, 0o700); save(directory, metadata);
|
|
89
|
+
return metadata;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const INSTALL = String.raw`$phase='validate'; Assert-Admin
|
|
93
|
+
if (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { throw 'Service already exists; no adoption' }
|
|
94
|
+
if (Test-Path -LiteralPath $p.remoteDirectory) { throw 'Directory already exists; no adoption' }
|
|
95
|
+
$phase='reserve-directory'
|
|
96
|
+
$parent=Split-Path -Parent $p.remoteDirectory
|
|
97
|
+
if (!(Test-Path -LiteralPath $parent)) { $null=New-Item -ItemType Directory -Path $parent }
|
|
98
|
+
$ancestor=Get-Item -LiteralPath $parent
|
|
99
|
+
while ($ancestor) { if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse ancestor forbidden' }; $ancestor=$ancestor.Parent }
|
|
100
|
+
$null=New-Item -ItemType Directory -Path $p.remoteDirectory
|
|
101
|
+
$phase='protect-directory'
|
|
102
|
+
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
103
|
+
$acl=New-Object Security.AccessControl.DirectorySecurity
|
|
104
|
+
$acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false)
|
|
105
|
+
foreach ($id in @($sid.Value,'S-1-5-18','S-1-5-32-544') | Select-Object -Unique) {
|
|
106
|
+
$who=New-Object Security.Principal.SecurityIdentifier($id)
|
|
107
|
+
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($who,'FullControl','ContainerInherit,ObjectInherit','None','Allow')))
|
|
108
|
+
}
|
|
109
|
+
$localService=New-Object Security.Principal.SecurityIdentifier('S-1-5-19')
|
|
110
|
+
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($localService,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
|
|
111
|
+
Set-Acl -LiteralPath $p.remoteDirectory -AclObject $acl
|
|
112
|
+
$actual=Get-Acl -LiteralPath $p.remoteDirectory
|
|
113
|
+
if (!$actual.AreAccessRulesProtected) { throw 'ACL protection failed' }
|
|
114
|
+
$allowed=@($sid.Value,'S-1-5-18','S-1-5-32-544','S-1-5-19')
|
|
115
|
+
foreach ($r in $actual.Access) { if ($r.AccessControlType -ne 'Allow' -or $r.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin $allowed) { throw 'Unexpected ACL rule' } }
|
|
116
|
+
$marker=@{owner=$p.owner;serviceName=$p.serviceName;instanceURL=$p.instanceURL;name=$p.name}
|
|
117
|
+
$marker | ConvertTo-Json -Compress | Set-Content -Encoding UTF8 -LiteralPath (Join-Path $p.remoteDirectory '.jsn-owner.json')
|
|
118
|
+
$phase='download'
|
|
119
|
+
$archive=Join-Path $p.remoteDirectory '.download.zip'
|
|
120
|
+
if ($p.archivePath) { Copy-Item -LiteralPath $p.archivePath -Destination $archive } else { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -UseBasicParsing -Uri $p.archiveURL -OutFile $archive -TimeoutSec 600 }
|
|
121
|
+
$phase='verify-archive'
|
|
122
|
+
if ((Get-Item -LiteralPath $archive).Length -gt 1GB) { throw 'Archive too large' }
|
|
123
|
+
$archiveHash=(Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash
|
|
124
|
+
if ($p.archiveSha256 -and $archiveHash -ne $p.archiveSha256) { throw 'Archive hash mismatch' }
|
|
125
|
+
$phase='inspect-archive'
|
|
126
|
+
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
127
|
+
$zip=[IO.Compression.ZipFile]::OpenRead($archive)
|
|
128
|
+
try {
|
|
129
|
+
if ($zip.Entries.Count -gt 20000) { throw 'Too many archive entries' }
|
|
130
|
+
$total=0; $seen=@{}
|
|
131
|
+
foreach ($entry in $zip.Entries) {
|
|
132
|
+
$entryName=$entry.FullName
|
|
133
|
+
if ($entryName.Contains('\') -or $entryName.StartsWith('/') -or $entryName.Contains(':')) { throw 'Unsafe archive path' }
|
|
134
|
+
$parts=$entryName.TrimEnd('/').Split('/')
|
|
135
|
+
if ($parts[0] -notin @('agent','META-INF')) { throw 'Unexpected archive root' }
|
|
136
|
+
foreach ($part in $parts) { if (!$part -or $part -eq '.' -or $part -eq '..' -or $part -match '[<>"|?*\x00-\x1f]' -or $part -match '[. ]$' -or $part -match '^(?i:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)') { throw 'Unsafe archive entry' } }
|
|
137
|
+
$key=$entryName.TrimEnd('/').ToLowerInvariant(); if ($seen.ContainsKey($key)) { throw 'Duplicate archive entry' }; $seen[$key]=$true
|
|
138
|
+
$mode=($entry.ExternalAttributes -shr 16) -band 0xF000
|
|
139
|
+
if ($mode -ne 0 -and $mode -ne 0x8000 -and $mode -ne 0x4000) { throw 'Archive links forbidden' }
|
|
140
|
+
$total+=$entry.Length; if ($total -gt 2GB) { throw 'Expanded archive too large' }
|
|
141
|
+
}
|
|
142
|
+
} finally { $zip.Dispose() }
|
|
143
|
+
$phase='extract'
|
|
144
|
+
Expand-Archive -LiteralPath $archive -DestinationPath $p.remoteDirectory
|
|
145
|
+
Remove-Item -LiteralPath $archive
|
|
146
|
+
$phase='vendor-contract'
|
|
147
|
+
$agent=Join-Path $p.remoteDirectory 'agent'
|
|
148
|
+
foreach ($relative in @('config.xml','conf\wrapper.conf','conf\wrapper-override.conf','bin\mid.bat','bin\wrapper-windows-x86-64.exe','jre\bin\java.exe')) { if (!(Test-Path -LiteralPath (Join-Path $agent $relative) -PathType Leaf)) { throw 'Unsupported vendor ZIP layout' } }
|
|
149
|
+
$batch=Get-Content -Raw -LiteralPath (Join-Path $agent 'bin\mid.bat')
|
|
150
|
+
$wrapper=Get-Content -Raw -LiteralPath (Join-Path $agent 'conf\wrapper.conf')
|
|
151
|
+
if ($batch -notmatch '\[install\]' -or $batch -notmatch '\[remove\]' -or $batch -notmatch '(?m)^\s*%_WRAPPER_EXE% -i ' -or $batch -notmatch '(?m)^\s*%_WRAPPER_EXE% -r ' -or $wrapper -notmatch '(?m)^wrapper.java.command=jre/bin/java' -or $wrapper -notmatch '(?m)^#include ../conf/wrapper-override.conf') { throw 'Unsupported vendor service contract' }
|
|
152
|
+
$modules=@(Get-ChildItem -LiteralPath $agent -Filter 'EnforceFilePermissions.psm1' -File -Recurse)
|
|
153
|
+
if ($modules.Count -ne 1 -or (Get-Content -Raw -LiteralPath $modules[0].FullName) -notmatch 'function\s+enforcePerms\s*\{') { throw 'Unsupported vendor permission enforcement contract' }
|
|
154
|
+
$phase='configure'
|
|
155
|
+
$config=Join-Path $agent 'config.xml'
|
|
156
|
+
$xml=New-Object Xml.XmlDocument; $xml.XmlResolver=$null; $xml.PreserveWhitespace=$true; $xml.Load($config)
|
|
157
|
+
if ($xml.DocumentElement.Name -ne 'parameters') { throw 'Unsupported configuration XML root' }
|
|
158
|
+
$values=@{url=($p.instanceURL+'/');'mid.instance.username'=$p.username;'mid.instance.password'=$p.password;name=$p.name;'mid.windows_host.file_permissions.allow_list'='S-1-5-19'}
|
|
159
|
+
foreach ($key in @($values.Keys)) {
|
|
160
|
+
$nodes=@($xml.SelectNodes('/parameters/parameter') | Where-Object { $_.GetAttribute('name') -eq $key })
|
|
161
|
+
if ($nodes.Count -gt 1) { throw 'Duplicate configuration parameter' }
|
|
162
|
+
if ($nodes.Count -eq 0) { $node=$xml.CreateElement('parameter'); $node.SetAttribute('name',$key); $null=$xml.DocumentElement.AppendChild($node) } else { $node=$nodes[0] }
|
|
163
|
+
if ($key -eq 'mid.windows_host.file_permissions.allow_list') {
|
|
164
|
+
$entries=@($node.GetAttribute('value').Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
165
|
+
$values[$key]=(@($entries + 'S-1-5-19') | Select-Object -Unique) -join ','
|
|
166
|
+
}
|
|
167
|
+
$node.SetAttribute('value',[string]$values[$key])
|
|
168
|
+
if ($key -eq 'mid.instance.password' -and !$node.HasAttribute('secure')) { $node.SetAttribute('secure','true') }
|
|
169
|
+
}
|
|
170
|
+
$xml.Save($config)
|
|
171
|
+
$override=Join-Path $agent 'conf\wrapper-override.conf'
|
|
172
|
+
$text=Get-Content -Raw -LiteralPath $override
|
|
173
|
+
$text=[regex]::Replace($text,'(?m)^wrapper\.(?:name|displayname|ntservice\.(?:account|account\.prompt|password|password\.prompt|starttype|permissions\.1\.(?:account|allow|service)))=.*\r?$','')
|
|
174
|
+
$text+="
|
|
175
|
+
wrapper.name=$($p.serviceName)
|
|
176
|
+
wrapper.displayname=ServiceNow MID $($p.serviceName)
|
|
177
|
+
wrapper.ntservice.account=NT AUTHORITY\LocalService
|
|
178
|
+
wrapper.ntservice.password=
|
|
179
|
+
wrapper.ntservice.account.prompt=FALSE
|
|
180
|
+
wrapper.ntservice.password.prompt=FALSE
|
|
181
|
+
wrapper.ntservice.starttype=AUTO_START
|
|
182
|
+
wrapper.ntservice.permissions.1.account=NT AUTHORITY\LocalService
|
|
183
|
+
wrapper.ntservice.permissions.1.allow=START, STOP
|
|
184
|
+
wrapper.ntservice.permissions.1.service=$($p.serviceName)
|
|
185
|
+
"
|
|
186
|
+
[IO.File]::WriteAllText($override,$text,(New-Object Text.UTF8Encoding($false)))
|
|
187
|
+
$phase='register-service'
|
|
188
|
+
Push-Location $agent
|
|
189
|
+
try { & cmd.exe /d /s /c 'bin\mid.bat install' 2>&1 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Vendor install failed' } } finally { Pop-Location }
|
|
190
|
+
$phase='verify-service'
|
|
191
|
+
Assert-Owned; $s=Owned-Service; if (!$s) { throw 'Service registration missing' }
|
|
192
|
+
if ($s.StartMode -ne 'Auto') { throw 'Service automatic startup was not configured' }
|
|
193
|
+
$phase='enforce-permissions'
|
|
194
|
+
Import-Module -Name $modules[0].FullName -Force -ErrorAction Stop
|
|
195
|
+
$enforce=Get-Command -Name enforcePerms -CommandType Function -ErrorAction Stop
|
|
196
|
+
if ($enforce.Module.Path -ne $modules[0].FullName) { throw 'Wrong enforcement module' }
|
|
197
|
+
# The vendor allow-list includes a second service principal when USERDOMAIN
|
|
198
|
+
# resolves it. Match the LocalService runtime context so its saved list/hash is
|
|
199
|
+
# identical on startup; let the unmodified vendor module compute the hash.
|
|
200
|
+
$previousUserDomain=$env:USERDOMAIN
|
|
201
|
+
try {
|
|
202
|
+
$env:USERDOMAIN=($s.StartName -split '\\',2)[0]
|
|
203
|
+
enforcePerms -folder $agent -serviceName $p.serviceName -debug $false -additionWhiteListedGroups $values['mid.windows_host.file_permissions.allow_list'] | Out-Null
|
|
204
|
+
} finally { $env:USERDOMAIN=$previousUserDomain }
|
|
205
|
+
$serviceRules=@((Get-Acl -LiteralPath $agent).Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -eq 'S-1-5-19' -and ($_.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -eq [Security.AccessControl.FileSystemRights]::FullControl })
|
|
206
|
+
if (!$serviceRules.Count) { throw 'Runtime account lacks enforced permissions' }
|
|
207
|
+
foreach ($receipt in @('fileperm.aclsave','fileperm.grphash')) { if (!(Test-Path -LiteralPath (Join-Path (Join-Path $agent 'etc') $receipt) -PathType Leaf)) { throw 'Vendor enforcement receipt missing' } }
|
|
208
|
+
Result @{status=$s.State;runtimeAccount=$s.StartName;serviceRegistered=$true;archiveSha256=$archiveHash;startMode=$s.StartMode}
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
export function installWindowsRemoteMidServer({ directory, releaseName, username, password, archivePath, archiveSha256, ...options }) {
|
|
212
|
+
const m = load(directory);
|
|
213
|
+
if (m.state !== 'prepared') throw new Error('Install requires a fresh prepared deployment');
|
|
214
|
+
if (!username || !password || [...String(username) + String(password)].some(char => char.codePointAt(0) < 32 && !['\t', '\n', '\r'].includes(char))) throw new Error('Valid MID credentials are required');
|
|
215
|
+
const archiveURL = buildMidZipUrl(releaseName, 'win32');
|
|
216
|
+
if (archiveSha256 && !/^[a-f0-9]{64}$/i.test(archiveSha256)) throw new Error('Invalid archive SHA256');
|
|
217
|
+
if (archivePath && (!archiveSha256 || !/^[A-Z]:\\[A-Za-z0-9 _.-]+(?:\\[A-Za-z0-9 _.-]+)*$/i.test(archivePath))) throw new Error('Cached remote archive requires an absolute safe path and inspected SHA256');
|
|
218
|
+
m.state = 'installing'; m.releaseName = releaseName; save(directory, m);
|
|
219
|
+
try {
|
|
220
|
+
const result = remote(m.target, INSTALL, { ...m, archiveURL, username, password, archivePath, archiveSha256 }, { ...options, timeout: 1200000 });
|
|
221
|
+
if (!result.serviceRegistered || !['NT AUTHORITY\\LocalService', 'LocalService'].includes(result.runtimeAccount)) throw new Error('Service installation was not verified under LocalService');
|
|
222
|
+
if (result.startMode !== 'Auto') throw new Error('Service automatic startup was not verified');
|
|
223
|
+
if (!/^[a-f0-9]{64}$/i.test(result.archiveSha256 || '')) throw new Error('Archive hash verification result missing');
|
|
224
|
+
m.startMode = result.startMode; m.archiveSha256 = result.archiveSha256.toLowerCase(); m.state = 'installed'; save(directory, m); return m;
|
|
225
|
+
} catch (error) { m.state = 'uncertain'; if (error.phase) m.failedPhase = error.phase; save(directory, m); throw error; }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function removeWindowsRemoteMidServer({ directory, ...options }) {
|
|
229
|
+
const m = load(directory);
|
|
230
|
+
m.state = 'removing'; save(directory, m);
|
|
231
|
+
try {
|
|
232
|
+
remote(m.target, String.raw`Assert-Owned; $s=Owned-Service
|
|
233
|
+
if ($s) {
|
|
234
|
+
Stop-Service -Name $p.serviceName -ErrorAction Stop
|
|
235
|
+
(Get-Service -Name $p.serviceName).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(60))
|
|
236
|
+
$null=Owned-Service
|
|
237
|
+
$agent=Join-Path $p.remoteDirectory 'agent'
|
|
238
|
+
Push-Location $agent
|
|
239
|
+
try { & cmd.exe /d /s /c 'bin\mid.bat remove' 2>&1 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Vendor removal failed' } } finally { Pop-Location }
|
|
240
|
+
$deadline=(Get-Date).AddSeconds(30)
|
|
241
|
+
while (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { if ((Get-Date) -gt $deadline) { throw 'Service still registered' }; Start-Sleep -Milliseconds 500 }
|
|
242
|
+
}
|
|
243
|
+
Assert-Owned
|
|
244
|
+
$links=@(Get-ChildItem -LiteralPath $p.remoteDirectory -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
|
|
245
|
+
if ($links.Count) { throw 'Refusing recursive cleanup through reparse points' }
|
|
246
|
+
$phase='stop-owned-processes'
|
|
247
|
+
if (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { throw 'Service still registered; refusing process cleanup' }
|
|
248
|
+
$ownedBinaries=@('agent\bin\wrapper-windows-x86-64.exe','agent\bin\wrapper.exe','agent\jre\bin\java.exe') | ForEach-Object { [IO.Path]::GetFullPath((Join-Path $p.remoteDirectory $_)) }
|
|
249
|
+
$candidates=@(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -in $ownedBinaries })
|
|
250
|
+
foreach ($candidate in $candidates) {
|
|
251
|
+
$current=Get-CimInstance Win32_Process -Filter ('ProcessId=' + [uint32]$candidate.ProcessId)
|
|
252
|
+
if (!$current) { continue }
|
|
253
|
+
if (!$current.ExecutablePath -or [IO.Path]::GetFullPath($current.ExecutablePath) -notin $ownedBinaries -or $current.ExecutablePath -ne $candidate.ExecutablePath -or $current.CreationDate -ne $candidate.CreationDate) { throw 'Owned process identity changed; refusing to kill reused PID' }
|
|
254
|
+
$process=Get-Process -Id $current.ProcessId -ErrorAction Stop
|
|
255
|
+
try {
|
|
256
|
+
$null=$process.Handle
|
|
257
|
+
if (!$process.Path -or [IO.Path]::GetFullPath($process.Path) -ne [IO.Path]::GetFullPath($current.ExecutablePath)) { throw 'Process executable changed' }
|
|
258
|
+
$process.Kill()
|
|
259
|
+
if (!$process.WaitForExit(30000)) { throw 'Owned process did not exit' }
|
|
260
|
+
} finally { $process.Dispose() }
|
|
261
|
+
}
|
|
262
|
+
$remaining=@(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -in $ownedBinaries })
|
|
263
|
+
if ($remaining.Count) { throw 'Owned runtime process remains; files retained' }
|
|
264
|
+
function Remove-OwnedFiles {
|
|
265
|
+
# Keep the owner receipt until all other children are gone, allowing a failed
|
|
266
|
+
# teardown to be safely retried without adopting an unmarked directory.
|
|
267
|
+
Get-ChildItem -LiteralPath $p.remoteDirectory -Force | Where-Object { $_.Name -ne '.jsn-owner.json' } | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop }
|
|
268
|
+
$receipt=Join-Path $p.remoteDirectory '.jsn-owner.json'
|
|
269
|
+
if (Test-Path -LiteralPath $receipt) { Remove-Item -LiteralPath $receipt -Force -ErrorAction Stop }
|
|
270
|
+
Remove-Item -LiteralPath $p.remoteDirectory -Force -ErrorAction Stop
|
|
271
|
+
}
|
|
272
|
+
$phase='remove-files'
|
|
273
|
+
try { Remove-OwnedFiles } catch [UnauthorizedAccessException] {
|
|
274
|
+
$phase='recover-file-acl'
|
|
275
|
+
# Service absence and the full tree scan were verified above. Do not expand
|
|
276
|
+
# recovery to any parent, sibling, global policy or unowned service.
|
|
277
|
+
if (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { throw 'Service reappeared; refusing ACL recovery' }
|
|
278
|
+
$remainingLinks=@(Get-ChildItem -LiteralPath $p.remoteDirectory -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
|
|
279
|
+
if ($remainingLinks.Count) { throw 'Refusing ACL recovery through reparse points' }
|
|
280
|
+
& takeown.exe /F $p.remoteDirectory /R /D Y 2>&1 | Out-Null
|
|
281
|
+
if ($LASTEXITCODE -ne 0) { throw 'Scoped ownership recovery failed; localized takeown prompts may require manual recovery' }
|
|
282
|
+
$controllerSid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
283
|
+
$grant='*' + $controllerSid + ':(OI)(CI)F'
|
|
284
|
+
& icacls.exe $p.remoteDirectory /grant $grant /T /C 2>&1 | Out-Null
|
|
285
|
+
if ($LASTEXITCODE -ne 0) { throw 'Scoped controller ACL recovery failed' }
|
|
286
|
+
$phase='remove-files'
|
|
287
|
+
Remove-OwnedFiles
|
|
288
|
+
}
|
|
289
|
+
if (Test-Path -LiteralPath $p.remoteDirectory) { throw 'Owned directory remains' }
|
|
290
|
+
Result @{removed=$true}`, m, { ...options, timeout: 180000 });
|
|
291
|
+
m.state = 'removed'; save(directory, m); return m;
|
|
292
|
+
} catch (error) { m.state = 'uncertain'; if (error.phase) m.failedPhase = error.phase; save(directory, m); throw error; }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function inspectWindowsRemoteMidServer({ directory, ...options }) {
|
|
296
|
+
const m = load(directory);
|
|
297
|
+
return { ...m, ...remote(m.target, `Assert-Owned; $s=Owned-Service; if (!$s) { throw 'Service missing' }; Result @{status=$s.State; runtimeAccount=$s.StartName; startMode=$s.StartMode}`, m, options) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function lifecycle(operation, { directory, ...options }) {
|
|
301
|
+
const m = load(directory);
|
|
302
|
+
try {
|
|
303
|
+
remote(m.target, `Assert-Owned; $s=Owned-Service; if (!$s) { throw 'Service missing' }; ${operation}-Service -Name $p.serviceName -ErrorAction Stop
|
|
304
|
+
(Get-Service -Name $p.serviceName).WaitForStatus('${operation === 'Start' ? 'Running' : 'Stopped'}',[TimeSpan]::FromSeconds(60))
|
|
305
|
+
Result @{status='${operation === 'Start' ? 'Running' : 'Stopped'}'}`, m, { ...options, timeout: 90000 });
|
|
306
|
+
m.state = operation === 'Start' ? 'running' : 'stopped'; save(directory, m); return m;
|
|
307
|
+
} catch (error) { m.state = 'uncertain'; if (error.phase) m.failedPhase = error.phase; save(directory, m); throw error; }
|
|
308
|
+
}
|
|
309
|
+
export const startWindowsRemoteMidServer = options => lifecycle('Start', options);
|
|
310
|
+
export const stopWindowsRemoteMidServer = options => lifecycle('Stop', options);
|
|
311
|
+
|
|
312
|
+
export function logsWindowsRemoteMidServer({ directory, tail = 100, ...options }) {
|
|
313
|
+
if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be between 1 and 10000');
|
|
314
|
+
const m = load(directory);
|
|
315
|
+
return remote(m.target, `Assert-Owned; $null=Owned-Service
|
|
316
|
+
$logs=@(Get-ChildItem -LiteralPath (Join-Path $p.remoteDirectory 'agent\\logs') -File | Where-Object { $_.Name -match '^agent.*\\.log(?:\\.[0-9]+)?$' } | Sort-Object LastWriteTime -Descending)
|
|
317
|
+
$text=''; if ($logs.Count -gt 0) { $text=(Get-Content -LiteralPath $logs[0].FullName -Tail $p.tail) -join [Environment]::NewLine }; Result @{logs=$text}`, { ...m, tail }, options).logs;
|
|
318
|
+
}
|
package/src/midserver-zip.js
CHANGED
|
@@ -17,7 +17,7 @@ function read(fd, size, offset) {
|
|
|
17
17
|
|
|
18
18
|
// Inspect every entry before extracting anything. ZIP64, split ZIPs, links and
|
|
19
19
|
// Windows aliases are deliberately unsupported, not delegated to a shell tool.
|
|
20
|
-
export function inspectMidZip(archive) {
|
|
20
|
+
export function inspectMidZip(archive, { allowedRoots = ['agent', 'META-INF'] } = {}) {
|
|
21
21
|
const fd = fs.openSync(archive, 'r');
|
|
22
22
|
try {
|
|
23
23
|
const size = fs.fstatSync(fd).size;
|
|
@@ -44,7 +44,7 @@ export function inspectMidZip(archive) {
|
|
|
44
44
|
const localOffset = directory.readUInt32LE(pos + 42);
|
|
45
45
|
const folder = name.endsWith('/');
|
|
46
46
|
const parts = name.replace(/\/$/, '').split('/');
|
|
47
|
-
if (!
|
|
47
|
+
if ((allowedRoots && !allowedRoots.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
48
|
const type = mode & 0o170000;
|
|
49
49
|
if (type && type !== 0o100000 && type !== 0o040000) throw new Error('ZIP links and special files are forbidden');
|
|
50
50
|
if ((flags & 1) || ![0, 8].includes(method) || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) throw new Error('Unsupported ZIP encoding');
|