@jacebenson/jsn 4.3.13 → 4.4.0-dev.113.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 +137 -0
- package/package.json +4 -3
- package/skills/servicenow/SKILL.md +20 -172
- package/src/app.js +29 -40
- package/src/auth-recovery.js +83 -0
- package/src/auth.js +2 -0
- package/src/capabilities.js +9 -0
- package/src/cli-adapter.js +12 -0
- package/src/cli.js +45 -9
- package/src/command-audience.js +38 -0
- package/src/commands/_generic.js +45 -28
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +24 -20
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/catalog-order.js +93 -0
- package/src/commands/catalog.js +8 -10
- package/src/commands/changes.js +2 -4
- package/src/commands/cmdb-search.js +38 -0
- package/src/commands/cmdb.js +2 -0
- package/src/commands/columns.js +2 -1
- package/src/commands/customizations.js +13 -7
- package/src/commands/discover.js +54 -0
- package/src/commands/docs/db.js +9 -1
- package/src/commands/docs/docs.js +25 -2
- package/src/commands/docs/ingest.js +20 -3
- package/src/commands/docs/install.js +86 -0
- package/src/commands/docs/locks.js +34 -0
- package/src/commands/docs/refresh.js +22 -2
- package/src/commands/docs/replace.js +30 -0
- package/src/commands/docs/snapshot-check.js +11 -0
- package/src/commands/docs/snapshot.js +130 -0
- package/src/commands/docs/sync.js +16 -6
- package/src/commands/forms.js +22 -97
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/knowledge.js +75 -0
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +57 -104
- package/src/commands/requests.js +9 -15
- package/src/commands/rest.js +15 -2
- package/src/commands/scan.js +234 -0
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +73 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +20 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +66 -13
- package/src/commands/upgrade.js +69 -0
- package/src/commands/version.js +14 -8
- package/src/constrained-query.js +20 -0
- package/src/context.js +6 -6
- package/src/customization-guidance.js +31 -0
- package/src/customizations-report.js +6 -0
- package/src/customizations.js +28 -13
- package/src/errors.js +23 -1
- package/src/essential-records.js +40 -0
- package/src/field-behavior.js +15 -0
- package/src/help.js +12 -2
- package/src/metadata/records.js +249 -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/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/mutations.js +3 -2
- package/src/output.js +34 -2
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +93 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +153 -0
- package/src/resolve-record.js +50 -28
- package/src/scan.js +249 -0
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- package/src/update-set-xml.js +89 -0
|
@@ -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');
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Capture-context approval is independent of delete confirmation preferences.
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { collectCapabilities } from './capabilities.js';
|
|
4
|
+
import { isMutationCommand } from './mutations.js';
|
|
5
|
+
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
6
|
+
import { canUsePicker, getStringField, quoteShellArg } from './helpers.js';
|
|
7
|
+
import { confirm } from './prompts.js';
|
|
8
|
+
import { AppError, CodeConfirmationRequired, errUsageHint } from './errors.js';
|
|
9
|
+
|
|
10
|
+
export function needsMutationContext(argv) {
|
|
11
|
+
if (!isMutationCommand(argv)) return false;
|
|
12
|
+
const caps = collectCapabilities().get(argv._?.[0]) || {};
|
|
13
|
+
if (caps.mutationContextReadMethods?.includes(String(argv.method || 'GET').toUpperCase())) return false;
|
|
14
|
+
return !caps.mutationContextExemptSubcommands?.some(sub =>
|
|
15
|
+
sub.split(' ').every((part, index) => argv._?.[index + 1] === part));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function diagnostic(code, message, command, severity = 'warning') {
|
|
19
|
+
return { code, severity, message, remediation: { command } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function guardMutationContext(app, argv, { ask = confirm, rawArgs = process.argv.slice(2) } = {}) {
|
|
23
|
+
if (!needsMutationContext(argv) || !app.sdk) return null;
|
|
24
|
+
let application, updateSet;
|
|
25
|
+
try {
|
|
26
|
+
const user = await getCurrentUser(app.sdk);
|
|
27
|
+
if (user?.sys_id) [application, updateSet] = await Promise.all([
|
|
28
|
+
getCurrentApplication(app.sdk, user.sys_id, { unknownWhenMissing: true }),
|
|
29
|
+
getCurrentUpdateSet(app.sdk, user.sys_id),
|
|
30
|
+
]);
|
|
31
|
+
} catch {
|
|
32
|
+
// Failed reads are unknown context, never evidence of Global + Default.
|
|
33
|
+
}
|
|
34
|
+
const context = { scope: application?.scope || null, application_id: application?.appSysId || null,
|
|
35
|
+
update_set: updateSet ? { name: updateSet.name, sys_id: updateSet.sys_id, state: updateSet.state || null } : null,
|
|
36
|
+
diagnostics: [], approval_required: false, approved: false };
|
|
37
|
+
if (!context.scope || !updateSet) context.diagnostics.push(diagnostic('capture_context_unknown',
|
|
38
|
+
'Capture context could not be determined. Identify the intended scope and update set before development changes.', 'jsn updatesets list'));
|
|
39
|
+
const appId = getStringField(updateSet || {}, 'application');
|
|
40
|
+
const rawAppId = updateSet?.application?.value || appId;
|
|
41
|
+
if (application?.appSysId && rawAppId && application.appSysId !== rawAppId) {
|
|
42
|
+
context.diagnostics.push(diagnostic('update_set_scope_mismatch',
|
|
43
|
+
`Selected scope ${context.scope} and update set ${updateSet.name} belong to different applications; changes may not be captured in this set.`,
|
|
44
|
+
`jsn scopes set ${quoteShellArg(rawAppId)}`));
|
|
45
|
+
}
|
|
46
|
+
if (updateSet?.state && !['in progress', 'in_progress'].includes(updateSet.state.toLowerCase())) {
|
|
47
|
+
context.diagnostics.push(diagnostic('update_set_not_in_progress', `Selected update set ${updateSet.name} is ${updateSet.state}; select an in-progress capture set.`, 'jsn updatesets set', 'error'));
|
|
48
|
+
const error = errUsageHint('Selected capture update set is not in progress.', 'Run: jsn updatesets set');
|
|
49
|
+
error.context = context; throw error;
|
|
50
|
+
}
|
|
51
|
+
const globalDefault = context.scope?.toLowerCase() === 'global' && updateSet?.name?.toLowerCase() === 'default';
|
|
52
|
+
if (globalDefault) {
|
|
53
|
+
context.approval_required = true;
|
|
54
|
+
context.diagnostics.push(diagnostic('global_default_update_set',
|
|
55
|
+
'Global scope + Default update set can capture customization in the wrong place or leave changes untracked. Select or create a dedicated update set; not all record changes are update-set tracked.',
|
|
56
|
+
'jsn updatesets create --name "My Feature"'));
|
|
57
|
+
if (argv.force) context.approved = true;
|
|
58
|
+
else if (canUsePicker(app, argv)) {
|
|
59
|
+
context.approved = await ask({ message: `Scope: Global; update set: Default. ${context.diagnostics.at(-1).message} Approve this exact mutation: ${['jsn', ...rawArgs].map(quoteShellArg).join(' ')}?`, default: false });
|
|
60
|
+
}
|
|
61
|
+
if (!context.approved) {
|
|
62
|
+
const target = app.session?.profileName ? ` --profile ${quoteShellArg(app.session.profileName)}` : '';
|
|
63
|
+
const rerun = ['jsn', ...rawArgs.filter(arg => arg !== '--force')].map(quoteShellArg).join(' ') + (rawArgs.includes('--profile') ? '' : target);
|
|
64
|
+
const error = new AppError(CodeConfirmationRequired, 'Mutation in Global scope + Default update set requires explicit approval.',
|
|
65
|
+
`Select a dedicated set: jsn updatesets set; or jsn updatesets create --name "My Feature". Ask the user to approve this exact operation, then rerun: ${rerun} --force`);
|
|
66
|
+
error.context = context; throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (canUsePicker(app, argv)) {
|
|
70
|
+
process.stderr.write(`Mutation context: scope ${context.scope || 'unknown'}; update set ${updateSet?.name || 'unknown'}${updateSet?.sys_id ? ` (${updateSet.sys_id})` : ''}.\n`);
|
|
71
|
+
for (const item of context.diagnostics) process.stderr.write(`Warning (${item.code}): ${item.message} Run: ${item.remediation.command}\n`);
|
|
72
|
+
}
|
|
73
|
+
return context;
|
|
74
|
+
}
|