node-command 2.0.3 → 2.0.4
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/bin/nc.mjs +964 -223
- package/lib/diagnostics-bundle.mjs +38 -10
- package/lib/setup.mjs +136 -7
- package/package.json +1 -1
|
@@ -17,7 +17,7 @@ import os from 'node:os';
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import zlib from 'node:zlib';
|
|
19
19
|
import child_process from 'node:child_process';
|
|
20
|
-
import { candidateInstallRoots, decideInstallMode, getNcRootForMode, probeInstallRoot } from './setup.mjs';
|
|
20
|
+
import { candidateInstallRoots, decideInstallMode, getNcRootForMode, probeInstallRoot, resolveExecutable } from './setup.mjs';
|
|
21
21
|
|
|
22
22
|
export const CLI_DIAGNOSTICS_VERSION = '1.0.0';
|
|
23
23
|
export const CLI_DIAGNOSTICS_MAX_LOG_BYTES = 65536;
|
|
@@ -121,13 +121,22 @@ export function parseCliDiagnosticsArgs(argv = []) {
|
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Best-effort service status for the platform agent units. Never throws.
|
|
124
|
+
* Executables resolve from the same absolute allowlist used by privileged
|
|
125
|
+
* paths, so the caller's PATH cannot redirect a diagnostic probe.
|
|
124
126
|
* @param {string} [platform]
|
|
127
|
+
* @param {{ run?: (cmd: string, args: string[], options: object) => any, resolve?: typeof resolveExecutable }} [options]
|
|
125
128
|
*/
|
|
126
|
-
export function probeCliServiceStatus(platform = os.platform()) {
|
|
129
|
+
export function probeCliServiceStatus(platform = os.platform(), { run = child_process.execFileSync, resolve = resolveExecutable } = {}) {
|
|
127
130
|
const checks = [];
|
|
128
|
-
const tryRun = (
|
|
131
|
+
const tryRun = (name, args) => {
|
|
132
|
+
let cmd;
|
|
129
133
|
try {
|
|
130
|
-
|
|
134
|
+
cmd = resolve(name, { platform });
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, stdout: '' };
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const out = run(cmd, args, {
|
|
131
140
|
timeout: 8000,
|
|
132
141
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
133
142
|
});
|
|
@@ -549,17 +558,36 @@ export function runCliDiagnostics(argv = [], { ncRoot = null, env = process.env,
|
|
|
549
558
|
writeFile ||
|
|
550
559
|
((f, d) => {
|
|
551
560
|
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
552
|
-
|
|
561
|
+
// Never follow an existing file or symlink at the predictable output
|
|
562
|
+
// name: O_EXCL makes a pre-created path fail closed instead of
|
|
563
|
+
// truncating its target.
|
|
564
|
+
const fd = fs.openSync(
|
|
565
|
+
f,
|
|
566
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0),
|
|
567
|
+
0o600
|
|
568
|
+
);
|
|
569
|
+
try {
|
|
570
|
+
fs.writeFileSync(fd, d);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
try {
|
|
573
|
+
fs.unlinkSync(f);
|
|
574
|
+
} catch {}
|
|
575
|
+
throw err;
|
|
576
|
+
} finally {
|
|
577
|
+
fs.closeSync(fd);
|
|
578
|
+
}
|
|
553
579
|
});
|
|
554
580
|
try {
|
|
555
581
|
writer(outPath, archive);
|
|
556
582
|
} catch (err) {
|
|
557
583
|
const hint =
|
|
558
|
-
err?.code === '
|
|
559
|
-
? 'Suggested action: choose a
|
|
560
|
-
: err?.code === '
|
|
561
|
-
? 'Suggested action:
|
|
562
|
-
:
|
|
584
|
+
err?.code === 'EEXIST'
|
|
585
|
+
? 'Suggested action: the output file already exists and is never overwritten; choose a different --output path and re-run.'
|
|
586
|
+
: err?.code === 'EACCES'
|
|
587
|
+
? 'Suggested action: choose a writable --output path or re-run with permission to write there.'
|
|
588
|
+
: err?.code === 'ENOSPC'
|
|
589
|
+
? 'Suggested action: free disk space and re-run.'
|
|
590
|
+
: 'Suggested action: choose a different --output path and re-run.';
|
|
563
591
|
return {
|
|
564
592
|
handled: true,
|
|
565
593
|
ok: false,
|
package/lib/setup.mjs
CHANGED
|
@@ -45,17 +45,69 @@ export function browserBrandReexecArgs(args, brand) {
|
|
|
45
45
|
return ['--input-type=module', '--eval', script];
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Canonical absolute Windows executable path derived only from the trusted
|
|
50
|
+
* SystemRoot (never from the caller's PATH).
|
|
51
|
+
* @param {'powershell'|'tar'|'sc'|'sc.exe'|'net'|'net.exe'} name
|
|
52
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
53
|
+
*/
|
|
54
|
+
export function windowsExecutablePath(name, env = process.env) {
|
|
55
|
+
const systemRoot = env.SystemRoot || env.windir || 'C:\\Windows';
|
|
56
|
+
if (name === 'powershell') return path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
57
|
+
if (name === 'tar') return path.join(systemRoot, 'System32', 'tar.exe');
|
|
58
|
+
if (name === 'sc' || name === 'sc.exe') return path.join(systemRoot, 'System32', 'sc.exe');
|
|
59
|
+
if (name === 'net' || name === 'net.exe') return path.join(systemRoot, 'System32', 'net.exe');
|
|
60
|
+
throw new Error(`No canonical Windows executable mapping for ${name}.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves a required helper executable only from an absolute allowlist, so a
|
|
65
|
+
* caller-controlled PATH cannot redirect an elevation or install flow.
|
|
66
|
+
* @param {'bash'|'tar'|'sudo'|'id'|'powershell'|'systemctl'|'launchctl'|'sc'|'sc.exe'|'net'|'net.exe'} name
|
|
67
|
+
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, existsSync?: (p: string) => boolean }} [options]
|
|
68
|
+
*/
|
|
69
|
+
export function resolveExecutable(name, { platform = os.platform(), env = process.env, existsSync = fs.existsSync } = {}) {
|
|
70
|
+
const unavailable = (expected) => Object.assign(
|
|
71
|
+
new Error(`Required executable ${name} was not found at ${expected}; refusing to resolve it through PATH.`),
|
|
72
|
+
{ code: 'CLI_EXECUTABLE_UNAVAILABLE' }
|
|
73
|
+
);
|
|
74
|
+
if (platform === 'win32') {
|
|
75
|
+
const candidate = windowsExecutablePath(name, env);
|
|
76
|
+
if (existsSync(candidate)) return candidate;
|
|
77
|
+
throw unavailable(candidate);
|
|
78
|
+
}
|
|
79
|
+
/** @type {Record<string, string[]>} */
|
|
80
|
+
const allowlist = {
|
|
81
|
+
bash: ['/bin/bash', '/usr/bin/bash'],
|
|
82
|
+
tar: ['/bin/tar', '/usr/bin/tar'],
|
|
83
|
+
sudo: ['/usr/bin/sudo', '/bin/sudo'],
|
|
84
|
+
id: ['/usr/bin/id', '/bin/id'],
|
|
85
|
+
systemctl: ['/usr/bin/systemctl', '/bin/systemctl', '/usr/sbin/systemctl', '/sbin/systemctl'],
|
|
86
|
+
launchctl: ['/bin/launchctl', '/usr/bin/launchctl']
|
|
87
|
+
};
|
|
88
|
+
const candidates = allowlist[name];
|
|
89
|
+
if (!candidates) throw new Error(`No POSIX executable allowlist for ${name}.`);
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
if (existsSync(candidate)) return candidate;
|
|
92
|
+
}
|
|
93
|
+
throw unavailable(candidates.join(' or '));
|
|
94
|
+
}
|
|
95
|
+
|
|
48
96
|
/** Single-line notice printed when setup falls back to user mode. */
|
|
49
97
|
export const LIMITED_CAPS_WARNING =
|
|
50
98
|
'limited: privileged_shell_exec and container tools are off (re-run as administrator for full capabilities)';
|
|
51
99
|
|
|
52
100
|
/**
|
|
53
101
|
* Detects Windows administrator rights via `net session` (exit 0 ⇔ admin).
|
|
54
|
-
*
|
|
102
|
+
* The production path resolves the absolute `net.exe`; an injected runner
|
|
103
|
+
* keeps the stable `net` command name for existing fixtures. Never throws:
|
|
104
|
+
* any failure means "not admin".
|
|
55
105
|
* @param {(cmd: string, args: string[]) => { status: number|null }} [run]
|
|
56
106
|
*/
|
|
57
107
|
export function detectWindowsAdmin(run = null) {
|
|
58
108
|
try {
|
|
109
|
+
if (!run && os.platform() !== 'win32') return false;
|
|
110
|
+
const command = run ? 'net' : windowsExecutablePath('net');
|
|
59
111
|
const runner =
|
|
60
112
|
run ||
|
|
61
113
|
((cmd, args) => {
|
|
@@ -66,7 +118,7 @@ export function detectWindowsAdmin(run = null) {
|
|
|
66
118
|
return { status: 1 };
|
|
67
119
|
}
|
|
68
120
|
});
|
|
69
|
-
return runner(
|
|
121
|
+
return runner(command, ['session']).status === 0;
|
|
70
122
|
} catch {
|
|
71
123
|
return false;
|
|
72
124
|
}
|
|
@@ -108,6 +160,16 @@ export function decideInstallMode(options = {}) {
|
|
|
108
160
|
return isElevated(options) ? 'system' : 'user';
|
|
109
161
|
}
|
|
110
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A partial per-user application (valid config + device key, missing or empty
|
|
165
|
+
* app/launcher.mjs) may be repaired in place from the stored host identity.
|
|
166
|
+
* Protected or root-managed system trees always require explicit maintenance.
|
|
167
|
+
* @param {{ mode?: 'system'|'user', protectedContext?: unknown, rootManagedPair?: boolean }} [options]
|
|
168
|
+
*/
|
|
169
|
+
export function canRepairUserApplication({ mode, protectedContext = null, rootManagedPair = false } = {}) {
|
|
170
|
+
return mode === 'user' && !protectedContext && rootManagedPair !== true;
|
|
171
|
+
}
|
|
172
|
+
|
|
111
173
|
/**
|
|
112
174
|
* True when the current user CAN elevate to a machine-wide install even when
|
|
113
175
|
* the process is not root yet: an already-elevated process, a Windows
|
|
@@ -124,7 +186,7 @@ export function canElevate(options = {}) {
|
|
|
124
186
|
// UAC prompt, so elevation is available even when not administrator.
|
|
125
187
|
if (platform === 'win32') return true;
|
|
126
188
|
try {
|
|
127
|
-
const r = child_process.spawnSync('id', ['-nG'], { encoding: 'utf8' });
|
|
189
|
+
const r = child_process.spawnSync(resolveExecutable('id', { platform }), ['-nG'], { encoding: 'utf8' });
|
|
128
190
|
const groups = r && typeof r.stdout === 'string' ? r.stdout : '';
|
|
129
191
|
return /\b(sudo|admin|wheel)\b/.test(groups);
|
|
130
192
|
} catch {
|
|
@@ -329,6 +391,22 @@ export function probeInstallRoot(root, io = {}) {
|
|
|
329
391
|
return 'foreign';
|
|
330
392
|
}
|
|
331
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Canonical machine-wide roots that exist but cannot be inspected by the
|
|
396
|
+
* current unprivileged caller. Setup must escalate before deciding anything
|
|
397
|
+
* about them: treating an unreadable system tree as absent would create a
|
|
398
|
+
* competing per-user agent beside a real system installation.
|
|
399
|
+
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
|
|
400
|
+
* @returns {string[]}
|
|
401
|
+
*/
|
|
402
|
+
export function unreadableSystemInstallations({ platform = os.platform(), env = process.env, homedir = os.homedir(), io = {} } = {}) {
|
|
403
|
+
if (platform !== 'linux') return [];
|
|
404
|
+
const envWithoutRoot = { ...env };
|
|
405
|
+
delete envWithoutRoot.NC_ROOT;
|
|
406
|
+
const root = getNcRootForMode('system', { platform, env: envWithoutRoot, homedir });
|
|
407
|
+
return probeInstallRoot(root, io) === 'unreadable' ? [root] : [];
|
|
408
|
+
}
|
|
409
|
+
|
|
332
410
|
/**
|
|
333
411
|
* Removal targets split by accessibility. `install` roots are removed by the
|
|
334
412
|
* local flow; `unreadable` roots (system installs seen from a non-root shell)
|
|
@@ -500,7 +578,7 @@ export function setupTarget({ platform = os.platform(), mode = decideInstallMode
|
|
|
500
578
|
const script = "$ErrorActionPreference='Stop'; $s=@(Get-CimInstance Win32_Service -Filter \"Name='NCClient'\" | Select-Object PathName); $t=@(Get-ScheduledTask -TaskPath '\\' | Where-Object { $_.TaskName -eq 'NC Client' } | ForEach-Object { $_.Actions | Select-Object WorkingDirectory }); ConvertTo-Json -Compress -Depth 4 @{services=$s;tasks=$t}";
|
|
501
579
|
let state;
|
|
502
580
|
try {
|
|
503
|
-
const result = run('powershell
|
|
581
|
+
const result = run(windowsExecutablePath('powershell'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 10000, maxBuffer: 65536, windowsHide: true });
|
|
504
582
|
if (result.status !== 0) stop('Windows service/task state is unreadable; inspect NCClient and the NC Client task through administrator maintenance.');
|
|
505
583
|
state = JSON.parse(String(result.stdout));
|
|
506
584
|
if (!Array.isArray(state.services) || !Array.isArray(state.tasks)) throw new Error('invalid service state');
|
|
@@ -624,7 +702,16 @@ export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, auth
|
|
|
624
702
|
} finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
|
|
625
703
|
};
|
|
626
704
|
const envPath = path.join(ncRoot, 'config/nc-client.env');
|
|
627
|
-
|
|
705
|
+
let envText;
|
|
706
|
+
try { envText = read(envPath); } catch (error) {
|
|
707
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
708
|
+
// A completed login-user migration retires the source configuration; a
|
|
709
|
+
// second run must not surface a raw filesystem error or repeat OS work.
|
|
710
|
+
if (exists(envPath + '.identity-backup'))
|
|
711
|
+
fail('this tree was already migrated to the restricted nodecommand identity; no account or service was changed');
|
|
712
|
+
fail('the selected root has no readable installation configuration; preserve it and repair explicitly');
|
|
713
|
+
}
|
|
714
|
+
const env = parseMaintenanceEnv(envText);
|
|
628
715
|
if (env.NC_CLIENT_ID !== hostId) fail('host ID mismatch; no token was consumed');
|
|
629
716
|
const identity = root => {
|
|
630
717
|
const data = parseMaintenanceEnv(read(path.join(root, 'config/nc-client.env')));
|
|
@@ -643,6 +730,10 @@ export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, auth
|
|
|
643
730
|
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail('source root is not a directory');
|
|
644
731
|
const loginMove = env.NC_SERVICE_MODE === 'user';
|
|
645
732
|
const oldUser = loginMove ? operatorUser : '_nc';
|
|
733
|
+
// Re-selecting the already-migrated restricted installation is a no-op that
|
|
734
|
+
// must fail closed with its own reason instead of an account message.
|
|
735
|
+
if (loginMove && operatorUser === 'nodecommand')
|
|
736
|
+
fail('this installation already runs as the restricted nodecommand identity; no account or service was changed');
|
|
646
737
|
if (!/^[a-z_][a-z0-9_-]*[$]?$/.test(oldUser || '') || oldUser === 'root' || oldUser === 'nodecommand') fail('select the existing ordinary account explicitly');
|
|
647
738
|
const account = command('/usr/bin/getent', ['passwd', oldUser]).trim().split(':');
|
|
648
739
|
if (account.length !== 7 || account[0] !== oldUser || !/^\d+$/.test(account[2]) || !/^\d+$/.test(account[3])) fail('account inventory is ambiguous');
|
|
@@ -798,6 +889,12 @@ export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, auth
|
|
|
798
889
|
const created = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
|
|
799
890
|
newUid = Number(created[2]); newGid = Number(created[3]);
|
|
800
891
|
if (!Number.isSafeInteger(newUid) || newUid <= 0 || !Number.isSafeInteger(newGid) || newGid <= 0 || created[5] !== home) fail('new account inventory mismatch');
|
|
892
|
+
// The created identity must itself be restricted: non-login shell and no
|
|
893
|
+
// supplementary groups (never a reused login or elevated account).
|
|
894
|
+
const createdGroups = command('/usr/bin/id', ['-nG', 'nodecommand']).trim().split(/\s+/).filter(Boolean);
|
|
895
|
+
if (createdGroups.length !== 1 || createdGroups[0] !== 'nodecommand' ||
|
|
896
|
+
!['/usr/sbin/nologin', '/sbin/nologin', '/bin/false', '/usr/bin/false'].includes(created[6]))
|
|
897
|
+
fail('new restricted account is not a dedicated non-login identity; repair explicitly');
|
|
801
898
|
const rewritten = { ...parseMaintenanceEnv(read(path.join(destination, 'config/nc-client.env'))), NC_SERVICE_MODE: 'user',
|
|
802
899
|
NC_ROOT: destination, NC_SERVICE_UNIT: 'nc-client.service', NC_OPERATOR_USER: 'nodecommand', NC_SERVICE_USER: 'nodecommand', NC_SERVICE_GROUP: 'nodecommand' };
|
|
803
900
|
for (const name of ['NC_ENV_FILE', 'NC_LOG_FILE', 'NC_DEVICE_KEY_FILE', 'NC_NODE_BIN']) {
|
|
@@ -945,7 +1042,7 @@ export const LINUX_PAIRED_UNINSTALL_ENTRY = '/opt/nc/libexec/privileged-helper/i
|
|
|
945
1042
|
* Never forward startup hooks or loader/environment authority to that shell.
|
|
946
1043
|
* @param {string} scriptPath
|
|
947
1044
|
* @param {string|{ platform?: string, execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [platform]
|
|
948
|
-
|
|
1045
|
+
* @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs, env?: NodeJS.ProcessEnv, homedir?: string }} [options]
|
|
949
1046
|
*/
|
|
950
1047
|
export function runUninstallerScript(scriptPath, platform = os.platform(), options = {}) {
|
|
951
1048
|
let opts = options || {};
|
|
@@ -955,8 +1052,40 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
|
|
|
955
1052
|
}
|
|
956
1053
|
const execFile = opts.execFile || child_process.execFileSync;
|
|
957
1054
|
if (platform === 'win32') {
|
|
1055
|
+
// NC-SRC-02: only the packaged uninstaller of a known installation root (or
|
|
1056
|
+
// the Administrators/SYSTEM-only copy the installer registers for the SYSTEM
|
|
1057
|
+
// task) may execute — never an arbitrary path from the caller.
|
|
1058
|
+
const winpath = path.win32;
|
|
1059
|
+
const denied = () => Object.assign(new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: refusing untrusted uninstall execution; preserve the selected tree and use explicitly trusted maintenance.'), { code: 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' });
|
|
1060
|
+
const env = opts.env || process.env;
|
|
1061
|
+
const homedir = opts.homedir || os.homedir();
|
|
1062
|
+
const programData = env.ProgramData || 'C:\\ProgramData';
|
|
1063
|
+
const expected = new Set([
|
|
1064
|
+
...candidateInstallRoots({ platform: 'win32', env, homedir }).map(root => winpath.join(root, 'uninstall.ps1')),
|
|
1065
|
+
winpath.join(programData, 'NC Protected', 'uninstall.ps1')
|
|
1066
|
+
].map(file => file.toLowerCase()));
|
|
1067
|
+
if (typeof scriptPath !== 'string' || !winpath.isAbsolute(scriptPath) ||
|
|
1068
|
+
winpath.normalize(scriptPath) !== scriptPath || /["`\x00-\x1F\x7F]/.test(scriptPath) ||
|
|
1069
|
+
winpath.basename(scriptPath).toLowerCase() !== 'uninstall.ps1' || !expected.has(scriptPath.toLowerCase())) throw denied();
|
|
1070
|
+
// Discovery is not execution trust: the packaged file itself must be the
|
|
1071
|
+
// regular (non-reparse) file at that path, with no redirected ancestry.
|
|
1072
|
+
const io = opts.io || fs;
|
|
1073
|
+
let st;
|
|
1074
|
+
try {
|
|
1075
|
+
st = io.lstatSync(scriptPath);
|
|
1076
|
+
} catch {
|
|
1077
|
+
throw denied();
|
|
1078
|
+
}
|
|
1079
|
+
if (st.isSymbolicLink() || !st.isFile()) throw denied();
|
|
1080
|
+
let real;
|
|
1081
|
+
try {
|
|
1082
|
+
real = io.realpathSync(scriptPath);
|
|
1083
|
+
} catch {
|
|
1084
|
+
throw denied();
|
|
1085
|
+
}
|
|
1086
|
+
if (winpath.normalize(real).toLowerCase() !== scriptPath.toLowerCase()) throw denied();
|
|
958
1087
|
execFile(
|
|
959
|
-
'powershell
|
|
1088
|
+
windowsExecutablePath('powershell', env),
|
|
960
1089
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
|
|
961
1090
|
{ stdio: 'inherit' }
|
|
962
1091
|
);
|