@jacebenson/jsn 4.3.13-dev.93.1 → 4.3.13-dev.95.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/README.md +14 -0
- package/package.json +1 -1
- package/src/cli-adapter.js +4 -0
- package/src/cli.js +17 -3
- package/src/command-audience.js +35 -0
- package/src/commands/auth.js +15 -0
- package/src/commands/discover.js +1 -1
- package/src/commands/midservers.js +117 -10
- package/src/help.js +2 -0
- 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/README.md
CHANGED
|
@@ -78,3 +78,17 @@ npm test
|
|
|
78
78
|
## License
|
|
79
79
|
|
|
80
80
|
MIT
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
### Commands for the connected account
|
|
84
|
+
|
|
85
|
+
Login and OAuth refresh check for the current account's `admin` role. Profiles
|
|
86
|
+
without that role show a compact command list focused on tickets, generic
|
|
87
|
+
records, people/groups, documentation, and connection management. If the role
|
|
88
|
+
check is unavailable, the profile also uses compact help; this does not assert
|
|
89
|
+
that the account lacks admin. Existing profiles keep full help until renewed.
|
|
90
|
+
|
|
91
|
+
This applies to root help, root JSON, `discover`, and root shell completion.
|
|
92
|
+
Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
|
|
93
|
+
every registered command. Explicit command paths remain available and ServiceNow
|
|
94
|
+
ACLs decide access. Reauthenticate (or refresh OAuth) after roles change.
|
package/package.json
CHANGED
package/src/cli-adapter.js
CHANGED
|
@@ -332,6 +332,10 @@ export class LocalCLI {
|
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
_help(node, path = []) {
|
|
335
|
+
if (!path.length && this.rootHelpRenderer) {
|
|
336
|
+
process.stdout.write(this.rootHelpRenderer() + "\n");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
335
339
|
const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
|
|
336
340
|
if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
|
|
337
341
|
if (node.children.length) lines.push('\nCommands:\n' + node.children.filter((n) => n.name !== this.completionCommand).map((n) => ` ${n.definition.command} ${n.definition.describe || ''}`).join('\n'));
|
package/src/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { visibleCommandRegistry } from './command-audience.js';
|
|
1
2
|
// Root CLI using the local yargs-compatible adapter
|
|
2
3
|
|
|
3
4
|
import yargs, { hideBin } from './cli-adapter.js';
|
|
@@ -148,6 +149,7 @@ export function buildCLI() {
|
|
|
148
149
|
},
|
|
149
150
|
])
|
|
150
151
|
.options({
|
|
152
|
+
'all-commands': { type: 'boolean', describe: 'Show the full command list, including admin and developer tools' },
|
|
151
153
|
reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
|
|
152
154
|
|
|
153
155
|
force: {
|
|
@@ -432,7 +434,7 @@ export function buildCLI() {
|
|
|
432
434
|
.command(skillCmd(wrap))
|
|
433
435
|
.command(docsCmd(wrap))
|
|
434
436
|
.command(versionCmd(wrap))
|
|
435
|
-
.command(discoverCmd(wrap, () => cliInstance.commandRegistry()))
|
|
437
|
+
.command(discoverCmd(wrap, (argv) => visibleCommandRegistry(cliInstance.commandRegistry(), app.config, argv)))
|
|
436
438
|
.demandCommand(1, 'You must specify a command')
|
|
437
439
|
// Completion plumbing under a hidden command name, so the explicit
|
|
438
440
|
// 'completion' command below owns the UX. (#176)
|
|
@@ -452,6 +454,12 @@ export function buildCLI() {
|
|
|
452
454
|
if (current && !current.startsWith('-') && depth === 0) {
|
|
453
455
|
out = [...new Set([...out, ...rootCommands])];
|
|
454
456
|
}
|
|
457
|
+
const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
|
|
458
|
+
const visible = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(options), options);
|
|
459
|
+
if (!depth) {
|
|
460
|
+
const names = new Set(visible.commands.flatMap(command => [command.name, ...command.aliases]));
|
|
461
|
+
out = out.filter(value => value.startsWith('-') || names.has(value));
|
|
462
|
+
}
|
|
455
463
|
done([...new Set(out)]);
|
|
456
464
|
});
|
|
457
465
|
})
|
|
@@ -557,7 +565,7 @@ export function buildCLI() {
|
|
|
557
565
|
if (typeof formatArgv?.get === 'string' && formatArgv.get) {
|
|
558
566
|
app.output.setJqFilter(formatArgv.get);
|
|
559
567
|
}
|
|
560
|
-
const { commands } = cliInstance.commandRegistry();
|
|
568
|
+
const { commands } = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv);
|
|
561
569
|
try {
|
|
562
570
|
app.ok({ commands }, {
|
|
563
571
|
summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
|
|
@@ -567,7 +575,7 @@ export function buildCLI() {
|
|
|
567
575
|
}
|
|
568
576
|
process.exit(0);
|
|
569
577
|
}
|
|
570
|
-
process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
|
|
578
|
+
process.stdout.write(renderHelp(loadConfig(formatArgv), visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv)) + '\n');
|
|
571
579
|
process.exit(0);
|
|
572
580
|
}
|
|
573
581
|
// Every other failure here (unknown command, missing required argument,
|
|
@@ -587,6 +595,12 @@ export function buildCLI() {
|
|
|
587
595
|
exitWithError(new AppError(CodeUsage, msg, hint), { app });
|
|
588
596
|
});
|
|
589
597
|
|
|
598
|
+
cliInstance.rootHelpRenderer = () => {
|
|
599
|
+
const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
|
|
600
|
+
const config = loadConfig(options);
|
|
601
|
+
return renderHelp(config, visibleCommandRegistry(cliInstance.commandRegistry(), config, options));
|
|
602
|
+
};
|
|
603
|
+
|
|
590
604
|
// Snapshot the root command names (plus aliases) for the completion
|
|
591
605
|
// filter. Done before any parse — command builders mutate the registry.
|
|
592
606
|
rootCommands = cliInstance.getInternalMethods().getCommandInstance().getCommands();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Presentation policy only. ServiceNow ACLs still authorize every request.
|
|
2
|
+
// Explicit command paths and --all-commands remain available to every account.
|
|
3
|
+
const BASIC_COMMANDS = new Set([
|
|
4
|
+
'incidents', 'changes', 'requests', 'tasks', 'tickets', 'approvals', 'records',
|
|
5
|
+
'groups', 'groupmembers', 'grouproles', 'users', 'setup', 'auth', 'skill',
|
|
6
|
+
'version', 'docs', 'history', 'discover', 'completion',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export async function detectCommandAudience(sdk, username, instanceURL = sdk?.baseURL) {
|
|
10
|
+
if (!username || typeof sdk?.listStrict !== 'function') return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const roles = await sdk.listStrict('sys_user_has_role', {
|
|
13
|
+
sysparm_query: 'user=javascript:gs.getUserID()^role.name=admin',
|
|
14
|
+
sysparm_fields: 'sys_id', sysparm_limit: '1',
|
|
15
|
+
}, { touchLastSeen: false });
|
|
16
|
+
return { username, instance_url: instanceURL, mode: roles.length ? 'admin' : 'basic', role_check: 'verified' };
|
|
17
|
+
} catch {
|
|
18
|
+
// Missing role access must not expand help. This is a presentation fallback,
|
|
19
|
+
// not a claim that the role query succeeded or an authorization boundary.
|
|
20
|
+
return { username, instance_url: instanceURL, mode: 'basic', role_check: 'unavailable' };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function commandAudience(config, { instance, allCommands = false } = {}) {
|
|
25
|
+
if (allCommands || instance) return 'all';
|
|
26
|
+
const name = config?.activeProfile || config?.defaultProfile;
|
|
27
|
+
const profile = config?.profiles?.[name];
|
|
28
|
+
const cached = profile?.command_audience;
|
|
29
|
+
return cached?.username === profile?.username && cached?.instance_url === profile?.instance_url && cached?.mode === 'basic' ? 'basic' : 'all';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function visibleCommandRegistry(registry, config, options = {}) {
|
|
33
|
+
if (commandAudience(config, options) !== 'basic') return registry;
|
|
34
|
+
return { ...registry, commands: registry.commands.filter(command => BASIC_COMMANDS.has(command.name)) };
|
|
35
|
+
}
|
package/src/commands/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { detectCommandAudience } from '../command-audience.js';
|
|
1
2
|
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
|
|
2
3
|
import { errUsage, errUsageHint, errConfirmationRequired } from '../errors.js';
|
|
3
4
|
import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
|
|
@@ -710,6 +711,8 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
710
711
|
// profile exists; preserve it when verification is unavailable.
|
|
711
712
|
username = username || targetUsername;
|
|
712
713
|
|
|
714
|
+
const commandAudience = await detectCommandAudience(app.sdk, username, instanceURL);
|
|
715
|
+
|
|
713
716
|
// Save to profiles — deduplicate by instance URL
|
|
714
717
|
if (!app.config.profiles) {
|
|
715
718
|
app.config.profiles = {};
|
|
@@ -740,7 +743,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
740
743
|
read_only: preferences.read_only,
|
|
741
744
|
skip_confirmations: preferences.skip_confirmations,
|
|
742
745
|
include_counts: preferences.include_counts,
|
|
746
|
+
...(commandAudience ? { command_audience: commandAudience } : {}),
|
|
743
747
|
};
|
|
748
|
+
if (!commandAudience) delete app.config.profiles[profileName].command_audience;
|
|
744
749
|
if (!targetProfile && interactive) {
|
|
745
750
|
try {
|
|
746
751
|
const domains = await import('./domains.js');
|
|
@@ -1050,12 +1055,22 @@ Examples:
|
|
|
1050
1055
|
const name = target.name || app.config.activeProfile || app.config.defaultProfile;
|
|
1051
1056
|
if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
|
|
1052
1057
|
app.config.profiles[name].domain_separation = hasDS;
|
|
1058
|
+
|
|
1053
1059
|
await saveConfig(app.config);
|
|
1054
1060
|
}
|
|
1055
1061
|
} catch {
|
|
1056
1062
|
// Non-fatal — capability probe is best-effort
|
|
1057
1063
|
}
|
|
1058
1064
|
|
|
1065
|
+
const audienceName = target.name || app.config.activeProfile || app.config.defaultProfile;
|
|
1066
|
+
const audienceProfile = app.config.profiles?.[audienceName];
|
|
1067
|
+
if (audienceProfile?.instance_url === instanceURL) {
|
|
1068
|
+
const sdk = app.getSDKForProfile
|
|
1069
|
+
? app.getSDKForProfile(instanceURL, { ...targetAuthOptions, profile: audienceProfile }) : app.sdk;
|
|
1070
|
+
audienceProfile.command_audience = await detectCommandAudience(sdk, audienceProfile.username, instanceURL);
|
|
1071
|
+
await saveConfig(app.config);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1059
1074
|
app.ok({
|
|
1060
1075
|
refreshed: true,
|
|
1061
1076
|
instance: instanceURL,
|
package/src/commands/discover.js
CHANGED
|
@@ -35,7 +35,7 @@ export function discoverCmd(wrap, getRegistry) {
|
|
|
35
35
|
'Use --json for structured results or --json --get data.commands for only matches.',
|
|
36
36
|
].join('\n')),
|
|
37
37
|
handler: wrap(async (argv, app) => {
|
|
38
|
-
const commands = discoverCommands(getRegistry(), { query: argv.query, table: argv.table });
|
|
38
|
+
const commands = discoverCommands(getRegistry(argv), { query: argv.query, table: argv.table });
|
|
39
39
|
const lines = commands.flatMap(command => [
|
|
40
40
|
`${command.name}: ${command.description}`,
|
|
41
41
|
...(command.discovery ? [
|
|
@@ -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',
|
package/src/help.js
CHANGED
|
@@ -203,6 +203,8 @@ function renderFlags(options) {
|
|
|
203
203
|
export function renderTips() {
|
|
204
204
|
return `
|
|
205
205
|
TIPS
|
|
206
|
+
Show every command, including admin and developer tools: "jsn --all-commands --help"
|
|
207
|
+
Command visibility follows the selected profile at authentication; explicit commands still use ServiceNow ACLs.
|
|
206
208
|
Find commands by task words: "jsn discover catalog"; by configured table: "jsn discover --table incident"
|
|
207
209
|
Cross-table task searches and custom tables: "jsn records list --help"
|
|
208
210
|
Specialized commands offer configured columns, lookup, and table-specific views; inspect discovery results for details
|
|
@@ -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');
|