node-command 2.0.2 → 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 +1289 -266
- package/lib/diagnostics-bundle.mjs +38 -10
- package/lib/setup.mjs +863 -46
- package/package.json +11 -16
package/lib/setup.mjs
CHANGED
|
@@ -8,6 +8,90 @@ import child_process from 'node:child_process';
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
/** Enrollment metadata is a brand, never an executable, profile or command. */
|
|
15
|
+
export function normalizeBrowserBrand(value) {
|
|
16
|
+
return value === 'edge' || value === 'chrome' ? value : 'unknown';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Capture HKCU before UAC; forwarded unknown must not query another account.
|
|
20
|
+
* @param {{platform?: string, forwarded?: string, run?: typeof child_process.spawnSync}} [options]
|
|
21
|
+
*/
|
|
22
|
+
export function captureBrowserDefaultBrand({ platform = os.platform(), forwarded = process.env.NC_BROWSER_ENROLL_BRAND, run = child_process.spawnSync } = {}) {
|
|
23
|
+
if (platform !== 'win32') return 'unknown';
|
|
24
|
+
if (forwarded !== undefined) return normalizeBrowserBrand(forwarded);
|
|
25
|
+
try {
|
|
26
|
+
const result = run('C:\\Windows\\System32\\reg.exe', [
|
|
27
|
+
'query', 'HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice', '/v', 'ProgId'
|
|
28
|
+
], { encoding: 'utf8', timeout: 1500, maxBuffer: 4096, windowsHide: true, shell: false });
|
|
29
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== 'string' || Buffer.byteLength(result.stdout) > 4096) return 'unknown';
|
|
30
|
+
const values = [...result.stdout.matchAll(/^\s*ProgId\s+REG_SZ\s+(\S+)\s*$/gim)];
|
|
31
|
+
if (values.length !== 1) return 'unknown';
|
|
32
|
+
if (/^MSEdgeHTM$/i.test(values[0][1])) return 'edge';
|
|
33
|
+
if (/^ChromeHTML$/i.test(values[0][1])) return 'chrome';
|
|
34
|
+
} catch {}
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Re-exec carries only a validated brand, including across credential UAC.
|
|
39
|
+
* Original CLI argv/dispatch are restored; no new public CLI flag is added.
|
|
40
|
+
* @param {string[]} args
|
|
41
|
+
* @param {string} brand
|
|
42
|
+
*/
|
|
43
|
+
export function browserBrandReexecArgs(args, brand) {
|
|
44
|
+
const script = `process.env.NC_BROWSER_ENROLL_BRAND=${JSON.stringify(normalizeBrowserBrand(brand))};process.argv=[process.execPath,...${JSON.stringify(args)}];await import(${JSON.stringify(pathToFileURL(path.resolve(args[0])).href)});`;
|
|
45
|
+
return ['--input-type=module', '--eval', script];
|
|
46
|
+
}
|
|
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
|
+
}
|
|
11
95
|
|
|
12
96
|
/** Single-line notice printed when setup falls back to user mode. */
|
|
13
97
|
export const LIMITED_CAPS_WARNING =
|
|
@@ -15,11 +99,15 @@ export const LIMITED_CAPS_WARNING =
|
|
|
15
99
|
|
|
16
100
|
/**
|
|
17
101
|
* Detects Windows administrator rights via `net session` (exit 0 ⇔ admin).
|
|
18
|
-
*
|
|
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".
|
|
19
105
|
* @param {(cmd: string, args: string[]) => { status: number|null }} [run]
|
|
20
106
|
*/
|
|
21
107
|
export function detectWindowsAdmin(run = null) {
|
|
22
108
|
try {
|
|
109
|
+
if (!run && os.platform() !== 'win32') return false;
|
|
110
|
+
const command = run ? 'net' : windowsExecutablePath('net');
|
|
23
111
|
const runner =
|
|
24
112
|
run ||
|
|
25
113
|
((cmd, args) => {
|
|
@@ -30,7 +118,7 @@ export function detectWindowsAdmin(run = null) {
|
|
|
30
118
|
return { status: 1 };
|
|
31
119
|
}
|
|
32
120
|
});
|
|
33
|
-
return runner(
|
|
121
|
+
return runner(command, ['session']).status === 0;
|
|
34
122
|
} catch {
|
|
35
123
|
return false;
|
|
36
124
|
}
|
|
@@ -72,6 +160,16 @@ export function decideInstallMode(options = {}) {
|
|
|
72
160
|
return isElevated(options) ? 'system' : 'user';
|
|
73
161
|
}
|
|
74
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
|
+
|
|
75
173
|
/**
|
|
76
174
|
* True when the current user CAN elevate to a machine-wide install even when
|
|
77
175
|
* the process is not root yet: an already-elevated process, a Windows
|
|
@@ -88,7 +186,7 @@ export function canElevate(options = {}) {
|
|
|
88
186
|
// UAC prompt, so elevation is available even when not administrator.
|
|
89
187
|
if (platform === 'win32') return true;
|
|
90
188
|
try {
|
|
91
|
-
const r = child_process.spawnSync('id', ['-nG'], { encoding: 'utf8' });
|
|
189
|
+
const r = child_process.spawnSync(resolveExecutable('id', { platform }), ['-nG'], { encoding: 'utf8' });
|
|
92
190
|
const groups = r && typeof r.stdout === 'string' ? r.stdout : '';
|
|
93
191
|
return /\b(sudo|admin|wheel)\b/.test(groups);
|
|
94
192
|
} catch {
|
|
@@ -134,10 +232,10 @@ export function candidateInstallRoots({ platform = os.platform(), env = process.
|
|
|
134
232
|
const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
|
|
135
233
|
roots.push(path.join(programData, 'NC'), path.join(localAppData, 'nc'));
|
|
136
234
|
} else if (platform === 'darwin') {
|
|
137
|
-
roots.push('/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
|
|
235
|
+
roots.push('/Library/Application Support/nc', '/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
|
|
138
236
|
} else {
|
|
139
237
|
const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
|
|
140
|
-
roots.push('/opt/nc', path.join(dataHome, 'nc'));
|
|
238
|
+
roots.push('/opt/nc', path.join(dataHome, 'nc'), '/var/lib/nodecommand/nc');
|
|
141
239
|
}
|
|
142
240
|
return [...new Set(roots)];
|
|
143
241
|
}
|
|
@@ -158,20 +256,119 @@ export function isInstallRoot(root, existsSync = fs.existsSync) {
|
|
|
158
256
|
}
|
|
159
257
|
}
|
|
160
258
|
|
|
259
|
+
/** Read-only recognition of the fixed protected single/dual-root producer.
|
|
260
|
+
* Protected records establish discovery, never permission to execute a tree.
|
|
261
|
+
*/
|
|
262
|
+
function linuxPairedInstallation(io = fs) {
|
|
263
|
+
io = { ...fs, ...io };
|
|
264
|
+
const helperRoot = '/opt/nc', pairedRoot = '/var/lib/nodecommand/nc';
|
|
265
|
+
const app = helperRoot + '/libexec/privileged-helper';
|
|
266
|
+
const lstat = io.lstatSync || fs.lstatSync;
|
|
267
|
+
const present = file => {
|
|
268
|
+
try { lstat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
269
|
+
};
|
|
270
|
+
const fail = () => { throw Object.assign(new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: paired Linux installation metadata is missing, unreadable, conflicting or unprotected; preserve both roots for independently authorized local Root maintenance.'), { code: 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' }); };
|
|
271
|
+
try {
|
|
272
|
+
if (!present(app)) {
|
|
273
|
+
if (present(helperRoot + '/libexec/nc-linux-peercred')) fail();
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const check = (file, directory = false, protectedPath = true) => {
|
|
277
|
+
if (io.realpathSync(file) !== file) fail();
|
|
278
|
+
for (let current = file; ; current = path.dirname(current)) {
|
|
279
|
+
const st = lstat(current);
|
|
280
|
+
if (io.realpathSync(current) !== current || st.isSymbolicLink() || (protectedPath && (st.uid !== 0 || st.gid !== 0 || (st.mode & 0o022))) ||
|
|
281
|
+
(current === file && !directory ? !st.isFile() || st.nlink !== 1 : !st.isDirectory())) fail();
|
|
282
|
+
if (current === path.dirname(current)) return;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const read = (file, protectedPath = true, mode = 0o600) => {
|
|
286
|
+
check(file, false, protectedPath);
|
|
287
|
+
const fd = io.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
288
|
+
try {
|
|
289
|
+
const before = io.fstatSync(fd), named = lstat(file);
|
|
290
|
+
if (!before.isFile() || before.nlink !== 1 || before.dev !== named.dev || before.ino !== named.ino ||
|
|
291
|
+
!Number.isSafeInteger(before.size) || before.size <= 0 || before.size > 65536 ||
|
|
292
|
+
(protectedPath && (before.uid !== 0 || before.gid !== 0 || (before.mode & 0o7777) !== mode))) fail();
|
|
293
|
+
const text = io.readFileSync(fd, 'utf8'), after = io.fstatSync(fd);
|
|
294
|
+
if (Buffer.byteLength(text) !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) fail();
|
|
295
|
+
return text;
|
|
296
|
+
} finally { io.closeSync(fd); }
|
|
297
|
+
};
|
|
298
|
+
check(app, true);
|
|
299
|
+
if (io.readdirSync(helperRoot + '/libexec').sort().join('\n') !== 'nc-linux-peercred\nprivileged-helper') fail();
|
|
300
|
+
check(helperRoot + '/libexec/nc-linux-peercred');
|
|
301
|
+
const bootstrap = JSON.parse(read(app + '/helper-bootstrap.json'));
|
|
302
|
+
const selection = JSON.parse(read('/var/lib/nc-lifecycle/selected.json'));
|
|
303
|
+
const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value) &&
|
|
304
|
+
Object.keys(value).length === fields.length && fields.every(field => Object.hasOwn(value, field));
|
|
305
|
+
const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value);
|
|
306
|
+
const controlPlaneId = value => typeof value === 'string' && value.length > 0 && value.length <= 256 &&
|
|
307
|
+
value.trim() === value && !/[\x00-\x1f\x7f]/.test(value);
|
|
308
|
+
const canonical = value => typeof value === 'string' && path.isAbsolute(value) && path.resolve(value) === value && !/[\x00-\x1f\x7f]/.test(value);
|
|
309
|
+
if (!exact(bootstrap, ['client_id', 'control_plane_id', 'tenant_id', 'root_consent', 'ack_private_key_file', 'helper_key_id',
|
|
310
|
+
'helper_key_binding_id', 'binding_state', 'node_bin', 'ordinary_root', 'ordinary_service_scope', 'lifecycle_custody_state']) ||
|
|
311
|
+
!/^nc_[0-9a-f]{64}$/.test(bootstrap.client_id) || !controlPlaneId(bootstrap.control_plane_id) || !id(bootstrap.tenant_id) ||
|
|
312
|
+
!id(bootstrap.helper_key_id) || bootstrap.root_consent !== true || ![helperRoot, pairedRoot].includes(bootstrap.ordinary_root) || bootstrap.lifecycle_custody_state !== 'prepared' ||
|
|
313
|
+
!['system', 'user'].includes(bootstrap.ordinary_service_scope) || !canonical(bootstrap.node_bin) ||
|
|
314
|
+
bootstrap.ack_private_key_file !== app + '/privileged-helper-ack-key.pem' || bootstrap.helper_key_binding_id !== null || bootstrap.binding_state !== 'pending') fail();
|
|
315
|
+
const ncRoot = bootstrap.ordinary_root;
|
|
316
|
+
if (ncRoot === helperRoot) {
|
|
317
|
+
const ordinaryEntries = ['app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'libexec', 'uninstall.sh', 'install-linux.sh'];
|
|
318
|
+
if (present(pairedRoot) || !io.readdirSync(ncRoot).every(entry => ordinaryEntries.includes(entry))) fail();
|
|
319
|
+
} else if (io.readdirSync(helperRoot).sort().join('\n') !== 'libexec') fail();
|
|
320
|
+
if (!exact(selection, ['version', 'control_plane_id', 'tenant_id', 'client_id', 'helper_key_binding_id', 'helper_key_id',
|
|
321
|
+
'installation_root', 'ordinary_uid', 'ordinary_service_scope', 'ordinary_service_unit', 'artifacts', 'bundle']) || selection.version !== 1 ||
|
|
322
|
+
['control_plane_id', 'tenant_id', 'client_id', 'helper_key_id'].some(field => selection[field] !== bootstrap[field]) ||
|
|
323
|
+
!id(selection.helper_key_binding_id) || selection.installation_root !== ncRoot || selection.ordinary_service_scope !== bootstrap.ordinary_service_scope ||
|
|
324
|
+
selection.ordinary_service_unit !== 'nc-client.service' || !Number.isSafeInteger(selection.ordinary_uid) || selection.ordinary_uid <= 0 || selection.ordinary_uid > 0xfffffffe) fail();
|
|
325
|
+
const serviceFile = '/etc/systemd/' + selection.ordinary_service_scope + '/nc-client.service';
|
|
326
|
+
const artifacts = [serviceFile, '/etc/systemd/system/nc-privileged-helper.service', '/etc/systemd/system/nc-privileged-helper.socket', '/etc/tmpfiles.d/nc-privileged-helper.conf'].sort();
|
|
327
|
+
const bundle = ['finalize-linux-uninstall.mjs', 'nc-linux-managed-work', 'node', 'privileged-helper-contract.mjs', 'privileged-helper-linux-managed-work.mjs', 'privileged-helper-protected-config.mjs'];
|
|
328
|
+
const validFile = (file, name, mode) => exact(file, ['path', 'sha256', 'size', 'mode']) && file.path === name && file.mode === mode &&
|
|
329
|
+
typeof file.sha256 === 'string' && /^[0-9a-f]{64}$/.test(file.sha256) && Number.isSafeInteger(file.size) && file.size > 0 && file.size <= 256 * 1024 * 1024;
|
|
330
|
+
if (!Array.isArray(selection.artifacts) || selection.artifacts.length !== artifacts.length ||
|
|
331
|
+
!selection.artifacts.every((file, index) => validFile(file, artifacts[index], 0o644)) ||
|
|
332
|
+
!Array.isArray(selection.bundle) || selection.bundle.length !== bundle.length ||
|
|
333
|
+
!selection.bundle.every((file, index) => validFile(file, bundle[index], ['node', 'nc-linux-managed-work'].includes(bundle[index]) ? 0o755 : 0o644))) fail();
|
|
334
|
+
const serviceArtifact = selection.artifacts.find(file => file.path === serviceFile);
|
|
335
|
+
const unit = read(serviceFile, true, 0o644);
|
|
336
|
+
const directories = [...unit.matchAll(/^\s*WorkingDirectory\s*=\s*(.+?)\s*$/gm)];
|
|
337
|
+
const launches = [...unit.matchAll(/^\s*ExecStart\s*=\s*(.+?)\s*$/gm)];
|
|
338
|
+
const launch = launches[0]?.[1].match(/^(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))$/);
|
|
339
|
+
if (directories.length !== 1 || ![ncRoot, ncRoot + '/app'].includes(directories[0][1]) || launches.length !== 1 ||
|
|
340
|
+
!launch || !canonical(launch[1] || launch[2]) || (launch[3] || launch[4]) !== ncRoot + '/app/launcher.mjs' ||
|
|
341
|
+
Buffer.byteLength(unit) !== serviceArtifact.size || crypto.createHash('sha256').update(unit).digest('hex') !== serviceArtifact.sha256) fail();
|
|
342
|
+
check(path.dirname(ncRoot), true);
|
|
343
|
+
check(ncRoot, true, ncRoot === helperRoot);
|
|
344
|
+
if (![0, selection.ordinary_uid].includes(lstat(ncRoot).uid)) fail();
|
|
345
|
+
check(ncRoot + '/config', true, false);
|
|
346
|
+
if (lstat(ncRoot + '/config').uid !== selection.ordinary_uid) fail();
|
|
347
|
+
const content = read(ncRoot + '/config/nc-client.env', false);
|
|
348
|
+
const ids = content.split(/\r?\n/).filter(line => /^\s*NC_CLIENT_ID\s*=/.test(line));
|
|
349
|
+
const clientId = ids[0]?.match(/^\s*NC_CLIENT_ID\s*=\s*(?:"(nc_[0-9a-f]{64})"|'(nc_[0-9a-f]{64})'|(nc_[0-9a-f]{64}))\s*$/);
|
|
350
|
+
if (ids.length !== 1 || !clientId || (clientId[1] || clientId[2] || clientId[3]) !== bootstrap.client_id) fail();
|
|
351
|
+
check(ncRoot + '/app/launcher.mjs', false, false);
|
|
352
|
+
return { ncRoot, helperRoot, clientId: bootstrap.client_id, ordinaryUid: selection.ordinary_uid,
|
|
353
|
+
serviceScope: selection.ordinary_service_scope, serviceFile, serviceArtifact };
|
|
354
|
+
} catch { fail(); }
|
|
355
|
+
}
|
|
356
|
+
|
|
161
357
|
/**
|
|
162
|
-
*
|
|
358
|
+
* Install-tree probe. A root-owned system tree (0750/0700) is
|
|
163
359
|
* unreadable to a non-root caller: a plain boolean would report "nothing to
|
|
164
360
|
* remove" for a machine-wide install that clearly exists, and the sudo path
|
|
165
361
|
* would never be reached. Distinguishes:
|
|
166
362
|
* - 'install' markers readable → normal removal path
|
|
167
363
|
* - 'absent' root directory does not exist
|
|
168
364
|
* - 'foreign' root exists and is traversable but carries no markers
|
|
365
|
+
* - 'paired-helper' protected metadata binds this helper to one ordinary root
|
|
169
366
|
* - 'unreadable' root exists but markers cannot be stat'ed (EACCES/EPERM)
|
|
170
367
|
* → needs elevation; the uninstaller's own safety guard
|
|
171
368
|
* still refuses non-NC trees when run under sudo.
|
|
172
369
|
* @param {string} root
|
|
173
370
|
* @param {{ statSync?: (p: string) => { isDirectory(): boolean }, accessSync?: (p: string, mode?: number) => void }} [io]
|
|
174
|
-
* @returns {'install'|'absent'|'foreign'|'unreadable'}
|
|
371
|
+
* @returns {'install'|'absent'|'foreign'|'unreadable'|'paired-helper'}
|
|
175
372
|
*/
|
|
176
373
|
export function probeInstallRoot(root, io = {}) {
|
|
177
374
|
const statSync = io.statSync || fs.statSync;
|
|
@@ -190,25 +387,51 @@ export function probeInstallRoot(root, io = {}) {
|
|
|
190
387
|
if (err && (err.code === 'EACCES' || err.code === 'EPERM')) return 'unreadable';
|
|
191
388
|
}
|
|
192
389
|
}
|
|
390
|
+
if (os.platform() === 'linux' && root === '/opt/nc' && linuxPairedInstallation(io)) return 'paired-helper';
|
|
193
391
|
return 'foreign';
|
|
194
392
|
}
|
|
195
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
|
+
|
|
196
410
|
/**
|
|
197
411
|
* Removal targets split by accessibility. `install` roots are removed by the
|
|
198
412
|
* local flow; `unreadable` roots (system installs seen from a non-root shell)
|
|
199
|
-
*
|
|
413
|
+
* report explicit trusted-maintenance requirements instead of being
|
|
200
414
|
* silently reported as "nothing to remove".
|
|
201
415
|
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
|
|
202
|
-
* @returns {{ install: string[], unreadable: string[] }}
|
|
416
|
+
* @returns {{ install: string[], unreadable: string[], paired?: { ncRoot: string, helperRoot: string, clientId: string }[] }}
|
|
203
417
|
*/
|
|
204
418
|
export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
205
419
|
const install = [];
|
|
206
420
|
const unreadable = [];
|
|
207
|
-
|
|
421
|
+
const pair = (rest.platform || os.platform()) === 'linux' ? linuxPairedInstallation(io) : null;
|
|
422
|
+
const env = rest.env || process.env;
|
|
423
|
+
if (pair && env.NC_ROOT && ![pair.ncRoot, pair.helperRoot].includes(env.NC_ROOT)) throw new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: NC_ROOT conflicts with the protected paired installation.');
|
|
424
|
+
const roots = candidateInstallRoots(pair ? { ...rest, env: { ...env, NC_ROOT: undefined } } : rest);
|
|
425
|
+
for (const root of pair ? [...new Set([...roots, pair.ncRoot])] : roots) {
|
|
426
|
+
if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
|
|
208
427
|
const status = probeInstallRoot(root, io);
|
|
209
428
|
if (status === 'install') install.push(root);
|
|
210
429
|
else if (status === 'unreadable') unreadable.push(root);
|
|
211
430
|
}
|
|
431
|
+
if (pair) {
|
|
432
|
+
if (install.length !== 1 || install[0] !== pair.ncRoot || unreadable.length) throw new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: conflicting paired installation inventory; preserve all roots.');
|
|
433
|
+
return { install, unreadable, paired: [pair] };
|
|
434
|
+
}
|
|
212
435
|
return { install, unreadable };
|
|
213
436
|
}
|
|
214
437
|
|
|
@@ -217,6 +440,7 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
|
217
440
|
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, existsSync?: (p: string) => boolean }} [options]
|
|
218
441
|
*/
|
|
219
442
|
export function findInstallations({ existsSync = fs.existsSync, ...rest } = {}) {
|
|
443
|
+
if ((rest.platform || os.platform()) === 'linux' && linuxPairedInstallation(rest.io || fs)) return findRemovalTargets(rest).install;
|
|
220
444
|
return candidateInstallRoots(rest).filter((r) => isInstallRoot(r, existsSync));
|
|
221
445
|
}
|
|
222
446
|
|
|
@@ -236,12 +460,545 @@ export function getNcRootForMode(mode, { platform = os.platform(), env = process
|
|
|
236
460
|
const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
|
|
237
461
|
return path.join(localAppData, 'nc');
|
|
238
462
|
}
|
|
239
|
-
if (mode === 'system') return '/opt/nc';
|
|
463
|
+
if (mode === 'system') return platform === 'darwin' ? '/Library/Application Support/nc' : '/opt/nc';
|
|
240
464
|
if (platform === 'darwin') return path.join(homedir, 'Library', 'Application Support', 'nc');
|
|
241
465
|
const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
|
|
242
466
|
return path.join(dataHome, 'nc');
|
|
243
467
|
}
|
|
244
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Resolve setup state before enrollment. Markers can prevent setup
|
|
471
|
+
* or preserve a root; they never authorize elevation or another user's mode.
|
|
472
|
+
* An explicit override does not hide an existing canonical tree or service.
|
|
473
|
+
* @param {{ platform?: string, mode?: 'system'|'user', env?: NodeJS.ProcessEnv, homedir?: string, uid?: number, io?: Pick<typeof fs, 'lstatSync'|'readFileSync'>, run?: typeof child_process.spawnSync }} [options]
|
|
474
|
+
* @returns {{ mode: 'system'|'user', ncRoot: string, existing: boolean, clientId?: string, protectedInstallation?: { ordinaryUid: number, serviceScope: string }, rootManagedPair?: { ordinaryUid: number, serviceScope: string } }}
|
|
475
|
+
*/
|
|
476
|
+
export function setupTarget({ platform = os.platform(), mode = decideInstallMode({ platform }), env = process.env, homedir = os.homedir(), uid = process.getuid?.(), io = fs, run = child_process.spawnSync } = {}) {
|
|
477
|
+
const stop = (reason) => { throw new Error(`${platform === 'darwin' ? 'macOS' : platform} setup stopped: ${reason}`); };
|
|
478
|
+
if (platform !== 'win32' && (!Number.isInteger(uid) || uid < 0)) stop('cannot verify the invoking user.');
|
|
479
|
+
const canonicalEnv = { ...env };
|
|
480
|
+
delete canonicalEnv.NC_ROOT;
|
|
481
|
+
const systemRoot = path.resolve(getNcRootForMode('system', { platform, env: canonicalEnv, homedir }));
|
|
482
|
+
const legacySystemRoot = platform === 'darwin' ? '/opt/nc' : null;
|
|
483
|
+
const userRoot = path.resolve(getNcRootForMode('user', { platform, env: canonicalEnv, homedir }));
|
|
484
|
+
if (env.NC_ROOT && !path.isAbsolute(env.NC_ROOT)) stop('NC_ROOT must be an absolute existing or new installation path.');
|
|
485
|
+
const explicitRoot = env.NC_ROOT ? path.resolve(env.NC_ROOT) : null;
|
|
486
|
+
const roots = [...new Set([systemRoot, legacySystemRoot, userRoot, ...(platform === 'linux' ? ['/var/lib/nodecommand/nc'] : []), explicitRoot].filter(Boolean))];
|
|
487
|
+
const pair = platform === 'linux' ? linuxPairedInstallation(io) : null;
|
|
488
|
+
const stat = (file) => {
|
|
489
|
+
try { return io.lstatSync(file); } catch (err) {
|
|
490
|
+
if (err?.code === 'ENOENT') return null;
|
|
491
|
+
stop(`existing installation state is unreadable or unverifiable at ${file}.`);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
const read = (file) => {
|
|
495
|
+
const info = stat(file);
|
|
496
|
+
if (!info) return null;
|
|
497
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536) stop('installation metadata is ambiguous.');
|
|
498
|
+
try {
|
|
499
|
+
const content = io.readFileSync(file, { encoding: 'utf8', flag: fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW });
|
|
500
|
+
if (Buffer.byteLength(content) > 65536) stop('installation metadata exceeds the supported bound.');
|
|
501
|
+
return content;
|
|
502
|
+
} catch { stop('existing installation metadata cannot be read safely.'); }
|
|
503
|
+
};
|
|
504
|
+
const installs = [];
|
|
505
|
+
for (const root of roots) {
|
|
506
|
+
if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
|
|
507
|
+
const info = stat(root);
|
|
508
|
+
if (!info) continue;
|
|
509
|
+
if (!info.isDirectory() || info.isSymbolicLink()) stop('an installation root is not a regular directory.');
|
|
510
|
+
const config = stat(path.join(root, 'config'));
|
|
511
|
+
if (!config?.isDirectory() || config.isSymbolicLink()) stop('an existing root has no verifiable NC configuration.');
|
|
512
|
+
const content = read(path.join(root, 'config', 'nc-client.env'));
|
|
513
|
+
if (content === null) stop('an existing root has no verifiable NC configuration.');
|
|
514
|
+
const entries = content.split(/\r?\n/).filter(line => /^\s*NC_SERVICE_MODE\s*=/.test(line));
|
|
515
|
+
const match = entries[0]?.match(/^\s*NC_SERVICE_MODE\s*=\s*(?:"(user|system)"|'(user|system)'|(user|system))\s*$/);
|
|
516
|
+
if (entries.length > 1 || (entries.length && !match)) stop('stored installation mode is ambiguous.');
|
|
517
|
+
const storedMode = match ? match[1] || match[2] || match[3] : null;
|
|
518
|
+
// Legacy canonical trees retain their installer ownership/layout. Linux
|
|
519
|
+
// custom trees can also identify their mode by owner; other custom trees
|
|
520
|
+
// require the persisted mode rather than guessing from current privilege.
|
|
521
|
+
const detectedMode = storedMode || (root === pair?.ncRoot ? info.uid === 0 ? 'system' : info.uid === uid && uid !== 0 ? 'user' : null : platform === 'win32'
|
|
522
|
+
? root === userRoot ? 'user' : root === systemRoot ? 'system' : null
|
|
523
|
+
: root === userRoot && info.uid === uid && uid !== 0 ? 'user' : (root === systemRoot || root === legacySystemRoot) && info.uid === 0 ? 'system'
|
|
524
|
+
: platform === 'linux' && root === explicitRoot ? info.uid === 0 ? 'system' : info.uid === uid ? 'user' : null : null);
|
|
525
|
+
// Root may verify this independently selected restricted pair, not adopt
|
|
526
|
+
// its user identity or execute a user installer. Management mode stays system.
|
|
527
|
+
const rootManagedPair = root === pair?.ncRoot && uid === 0 && process.getuid?.() === 0 && process.geteuid?.() === 0 && mode === 'system' &&
|
|
528
|
+
storedMode === 'user' && pair.serviceScope === 'user' && info.uid === pair.ordinaryUid && config.uid === pair.ordinaryUid;
|
|
529
|
+
if (!detectedMode || (!rootManagedPair && platform !== 'win32' && (detectedMode === 'user' ? info.uid !== uid || uid === 0 || config.uid !== uid : info.uid !== 0))) stop(`existing mode/ownership cannot be preserved safely at ${root}.`);
|
|
530
|
+
if (((root === systemRoot || root === legacySystemRoot) && detectedMode !== 'system') || (root === userRoot && detectedMode !== 'user')) stop(`stored mode conflicts with the installation root ${root}.`);
|
|
531
|
+
for (const name of ['NC_CLIENT_ID', 'NC_CLIENT_TOKEN', 'NC_SERVER_URL']) {
|
|
532
|
+
if (content.split(/\r?\n/).filter(line => new RegExp(`^\\s*${name}\\s*=`).test(line)).length > 1) stop(`duplicate host metadata at ${root}.`);
|
|
533
|
+
}
|
|
534
|
+
installs.push({ mode: detectedMode, ncRoot: root, rootManagedPair });
|
|
535
|
+
}
|
|
536
|
+
const services = [];
|
|
537
|
+
for (const [file, serviceMode, owner] of platform === 'darwin' ? [
|
|
538
|
+
['/Library/LaunchDaemons/com.nc.client.plist', 'system', 0],
|
|
539
|
+
[path.join(homedir, 'Library', 'LaunchAgents', 'com.nc.client.plist'), 'user', uid]
|
|
540
|
+
] : []) {
|
|
541
|
+
const info = stat(file);
|
|
542
|
+
if (!info) continue;
|
|
543
|
+
const content = read(file);
|
|
544
|
+
const labels = [...content.matchAll(/<key>Label<\/key>\s*<string>([^<]*)<\/string>/g)];
|
|
545
|
+
const directories = [...content.matchAll(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/g)];
|
|
546
|
+
if (info.uid !== owner || labels.length !== 1 || labels[0][1] !== 'com.nc.client' || directories.length !== 1) stop('existing service metadata is ambiguous.');
|
|
547
|
+
const directory = directories[0][1].replace(/&(amp|lt|gt|quot|apos);/g, (_, entity) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[entity]);
|
|
548
|
+
if (!path.isAbsolute(directory) || path.basename(directory) !== 'app') stop('existing service root cannot be verified.');
|
|
549
|
+
const serviceRoot = path.dirname(path.resolve(directory));
|
|
550
|
+
if (!installs.some(install => install.ncRoot === serviceRoot && install.mode === serviceMode)) stop('an existing service targets a different or unverifiable root. Preserve it through its maintenance flow.');
|
|
551
|
+
services.push(serviceRoot);
|
|
552
|
+
}
|
|
553
|
+
if (platform === 'linux') {
|
|
554
|
+
for (const [file, serviceMode] of [
|
|
555
|
+
['/etc/systemd/system/nc-client.service', 'system'],
|
|
556
|
+
['/etc/systemd/user/nc-client.service', 'user'],
|
|
557
|
+
[path.join(homedir, '.config/systemd/user/nc-client.service'), 'user'],
|
|
558
|
+
...(env.XDG_CONFIG_HOME ? [[path.join(env.XDG_CONFIG_HOME, 'systemd/user/nc-client.service'), 'user']] : [])
|
|
559
|
+
]) {
|
|
560
|
+
const content = read(file);
|
|
561
|
+
if (content === null) continue;
|
|
562
|
+
if (pair && file !== pair.serviceFile) stop(`another ordinary service exists at ${file}; preserve the paired installation through explicit Root maintenance.`);
|
|
563
|
+
const directories = [...content.matchAll(/^\s*WorkingDirectory\s*=\s*(.+?)\s*$/gm)];
|
|
564
|
+
const directory = directories[0]?.[1];
|
|
565
|
+
if (directories.length !== 1 || !path.isAbsolute(directory)) stop(`existing service metadata is ambiguous at ${file}.`);
|
|
566
|
+
const pairedService = pair && file === pair.serviceFile;
|
|
567
|
+
const serviceRoot = pairedService && directory === pair.ncRoot ? pair.ncRoot : path.basename(directory) === 'app' ? path.dirname(path.resolve(directory)) : null;
|
|
568
|
+
if (pairedService) {
|
|
569
|
+
if (Buffer.byteLength(content) !== pair.serviceArtifact.size || crypto.createHash('sha256').update(content).digest('hex') !== pair.serviceArtifact.sha256) stop(`paired service ${file} changed; explicit Root maintenance is required.`);
|
|
570
|
+
}
|
|
571
|
+
if (!installs.some(install => install.ncRoot === serviceRoot && (pairedService || install.mode === serviceMode))) stop(`existing service ${file} targets a different or unverifiable root; use its maintenance flow.`);
|
|
572
|
+
services.push(serviceRoot);
|
|
573
|
+
}
|
|
574
|
+
if (pair && !services.includes(pair.ncRoot)) stop('paired ordinary service is missing; explicit Root maintenance is required.');
|
|
575
|
+
}
|
|
576
|
+
if (platform === 'win32') {
|
|
577
|
+
// Discovery only: never start/stop tasks or services to identify a target.
|
|
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}";
|
|
579
|
+
let state;
|
|
580
|
+
try {
|
|
581
|
+
const result = run(windowsExecutablePath('powershell'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 10000, maxBuffer: 65536, windowsHide: true });
|
|
582
|
+
if (result.status !== 0) stop('Windows service/task state is unreadable; inspect NCClient and the NC Client task through administrator maintenance.');
|
|
583
|
+
state = JSON.parse(String(result.stdout));
|
|
584
|
+
if (!Array.isArray(state.services) || !Array.isArray(state.tasks)) throw new Error('invalid service state');
|
|
585
|
+
} catch { stop('Windows service/task state cannot be verified; inspect NCClient and the NC Client task through administrator maintenance.'); }
|
|
586
|
+
for (const service of state.services) {
|
|
587
|
+
const executable = service.PathName?.match(/^"([^"]+\\service\\nc-service\.exe)"\s/i)?.[1];
|
|
588
|
+
const root = executable && path.resolve(path.dirname(path.dirname(executable)));
|
|
589
|
+
if (!root || !installs.some(install => install.ncRoot.toLowerCase() === root.toLowerCase() && install.mode === 'system')) stop('NCClient targets a different or unverifiable root; use its maintenance flow.');
|
|
590
|
+
services.push(root);
|
|
591
|
+
}
|
|
592
|
+
for (const task of state.tasks) {
|
|
593
|
+
const root = typeof task.WorkingDirectory === 'string' && path.isAbsolute(task.WorkingDirectory) ? path.resolve(task.WorkingDirectory) : null;
|
|
594
|
+
if (!root || !installs.some(install => install.ncRoot.toLowerCase() === root.toLowerCase())) stop('the NC Client task targets a different or unverifiable root; use its maintenance flow.');
|
|
595
|
+
services.push(root);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (installs.length > 1 || new Set(services.map(root => platform === 'win32' ? root.toLowerCase() : root)).size > 1) stop(`conflicting installations/services exist at ${installs.map(install => install.ncRoot).join(', ')}; select a maintenance target; no competing agent will be created.`);
|
|
599
|
+
const existing = installs[0];
|
|
600
|
+
if (existing && explicitRoot && explicitRoot !== existing.ncRoot) stop('NC_ROOT would replace an existing installation root.');
|
|
601
|
+
if (existing && existing.mode !== mode && !existing.rootManagedPair) stop('preserve the existing mode/root; system maintenance requires an explicitly elevated invocation, not a second user install or user impersonation.');
|
|
602
|
+
if ((env.NC_MODE && env.NC_MODE !== mode) || (env.NC_USER_INSTALL === '1' && mode !== 'user') || (env.NC_SYSTEM_INSTALL === '1' && mode !== 'system')) stop('installer environment conflicts with the invoking privilege/mode.');
|
|
603
|
+
return { mode, ncRoot: existing?.ncRoot || explicitRoot || (mode === 'system' ? systemRoot : userRoot), existing: !!existing,
|
|
604
|
+
...(pair ? { clientId: pair.clientId, protectedInstallation: { ordinaryUid: pair.ordinaryUid, serviceScope: pair.serviceScope } } : {}),
|
|
605
|
+
...(existing?.rootManagedPair ? { rootManagedPair: { ordinaryUid: pair.ordinaryUid, serviceScope: pair.serviceScope } } : {}) };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Preserve the macOS entry used by existing callers and fixtures. */
|
|
609
|
+
export function macosSetupTarget({ uid = process.getuid?.(), ...options } = {}) {
|
|
610
|
+
return setupTarget({ ...options, platform: 'darwin', uid, mode: uid === 0 ? 'system' : 'user' });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Parse installation metadata as bounded data, NEVER as a root shell script. */
|
|
614
|
+
export function parseMaintenanceEnv(text) {
|
|
615
|
+
if (Buffer.byteLength(text) > 65536) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
|
|
616
|
+
const result = Object.create(null);
|
|
617
|
+
for (const line of text.split(/\r?\n/)) {
|
|
618
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
619
|
+
const match = line.match(/^(NC_[A-Z0-9_]+)=(.*)$/);
|
|
620
|
+
if (!match || Object.hasOwn(result, match[1]) || /[\x00-\x1f]/.test(match[2])) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
|
|
621
|
+
let value = match[2];
|
|
622
|
+
if (/^["']/.test(value)) {
|
|
623
|
+
if (value.at(-1) !== value[0]) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
|
|
624
|
+
value = value.slice(1, -1);
|
|
625
|
+
}
|
|
626
|
+
result[match[1]] = value;
|
|
627
|
+
}
|
|
628
|
+
if (!/^nc_[0-9a-f]{64}$/.test(result.NC_CLIENT_ID || '') || !result.NC_CLIENT_TOKEN ||
|
|
629
|
+
new URL(result.NC_SERVER_URL).protocol !== 'https:') throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
|
|
630
|
+
return result;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Import only an already provisioned root-protected producer. Neither npx
|
|
634
|
+
* ordinary cache code nor an agent-writable helper module may hold ACK keys.
|
|
635
|
+
*/
|
|
636
|
+
export async function loadProtectedHelperProducer(root) {
|
|
637
|
+
if (process.geteuid?.() !== 0 || !path.isAbsolute(root) || process.env.NODE_OPTIONS || process.env.NODE_PATH)
|
|
638
|
+
throw new Error('PRIVILEGED_HELPER_ROOT_MAINTENANCE_REQUIRED');
|
|
639
|
+
const check = file => {
|
|
640
|
+
for (let p = file; ; p = path.dirname(p)) {
|
|
641
|
+
const st = fs.lstatSync(p);
|
|
642
|
+
if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) throw new Error('PRIVILEGED_HELPER_UNPROTECTED_PATH');
|
|
643
|
+
if (path.dirname(p) === p) break;
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
check(fs.realpathSync(process.execPath)); check(fileURLToPath(import.meta.url)); check(fs.realpathSync(process.argv[1]));
|
|
647
|
+
const app = path.join(root, 'libexec/privileged-helper');
|
|
648
|
+
const visit = file => {
|
|
649
|
+
check(file); const st = fs.lstatSync(file);
|
|
650
|
+
if (st.isDirectory()) for (const name of fs.readdirSync(file)) visit(path.join(file, name));
|
|
651
|
+
else if (!st.isFile()) throw new Error('PRIVILEGED_HELPER_UNPROTECTED_PATH');
|
|
652
|
+
};
|
|
653
|
+
visit(app);
|
|
654
|
+
return import(pathToFileURL(path.join(app, 'privileged-helper-provision.mjs')).href);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Selected Linux identity maintenance. All OS mutation is behind complete
|
|
659
|
+
* target/identity/rollback preflight. The caller is explicitly privileged;
|
|
660
|
+
* verifyReconnection must independently confirm the same target in normal
|
|
661
|
+
* authenticated management, not merely accept a local agent status claim.
|
|
662
|
+
* home/io/run are injectable solely for the existing disposable fixtures.
|
|
663
|
+
*/
|
|
664
|
+
export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, authorizeMaintenance, verifyReconnection,
|
|
665
|
+
home = '/var/lib/nodecommand', systemUnit = '/etc/systemd/system/nc-client.service', io = fs, run = child_process.spawnSync, uid = process.geteuid?.() } = {}) {
|
|
666
|
+
if (uid !== 0 || os.platform() !== 'linux') throw new Error('IDENTITY_MAINTENANCE_ROOT_REQUIRED');
|
|
667
|
+
const fail = reason => { throw new Error(`IDENTITY_MAINTENANCE_REFUSED:${reason}`); };
|
|
668
|
+
if (!/^nc_[0-9a-f]{64}$/.test(hostId || '') || !ncRoot || path.resolve(ncRoot) !== ncRoot ||
|
|
669
|
+
!path.isAbsolute(home) || /[\n\r\x00$%"\\]/.test(ncRoot + home) || typeof authorizeMaintenance !== 'function' || typeof verifyReconnection !== 'function') fail('select the exact root, host and authenticated maintenance/reconnection verification first');
|
|
670
|
+
const command = (exe, args, { optional = false } = {}) => {
|
|
671
|
+
const result = run(exe, args, { encoding: 'utf8', shell: false, timeout: 60000, maxBuffer: 65536,
|
|
672
|
+
env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
|
|
673
|
+
if (result.error || result.status !== 0) { if (optional && !result.error && result.status === 2) return null; fail(`${path.basename(exe)} failed; preserve this installation and repair explicitly`); }
|
|
674
|
+
return String(result.stdout || '');
|
|
675
|
+
};
|
|
676
|
+
const exists = file => { try { return io.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
677
|
+
const directoryFd = directory => {
|
|
678
|
+
let fd = io.openSync('/', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
|
679
|
+
try {
|
|
680
|
+
for (const component of path.resolve(directory).split('/').filter(Boolean)) {
|
|
681
|
+
const next = io.openSync(`/proc/self/fd/${fd}/${component}`, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
682
|
+
io.closeSync(fd); fd = next;
|
|
683
|
+
}
|
|
684
|
+
return fd;
|
|
685
|
+
} catch (error) { io.closeSync(fd); throw error; }
|
|
686
|
+
};
|
|
687
|
+
const read = file => {
|
|
688
|
+
const parent = directoryFd(path.dirname(file));
|
|
689
|
+
let fd;
|
|
690
|
+
try { fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); }
|
|
691
|
+
finally { io.closeSync(parent); }
|
|
692
|
+
try { const st = io.fstatSync(fd); if (!st.isFile() || st.size > 65536) fail('unsafe installation metadata'); return io.readFileSync(fd, 'utf8'); }
|
|
693
|
+
finally { io.closeSync(fd); }
|
|
694
|
+
};
|
|
695
|
+
const write = (file, text, mode) => {
|
|
696
|
+
const parent = directoryFd(path.dirname(file)); let fd;
|
|
697
|
+
try {
|
|
698
|
+
fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}`, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, mode);
|
|
699
|
+
const st = io.fstatSync(fd);
|
|
700
|
+
if (!st.isFile() || st.nlink !== 1) fail('maintenance destination is not a single regular file');
|
|
701
|
+
io.ftruncateSync(fd, 0); io.writeFileSync(fd, text); io.fsyncSync(fd);
|
|
702
|
+
} finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
|
|
703
|
+
};
|
|
704
|
+
const envPath = path.join(ncRoot, 'config/nc-client.env');
|
|
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);
|
|
715
|
+
if (env.NC_CLIENT_ID !== hostId) fail('host ID mismatch; no token was consumed');
|
|
716
|
+
const identity = root => {
|
|
717
|
+
const data = parseMaintenanceEnv(read(path.join(root, 'config/nc-client.env')));
|
|
718
|
+
const key = crypto.createPrivateKey(read(path.join(root, 'config/nc-device-key.pem')));
|
|
719
|
+
return { client_id: data.NC_CLIENT_ID, server_url: data.NC_SERVER_URL,
|
|
720
|
+
device_key_id: crypto.createHash('sha256').update(crypto.createPublicKey(key).export({ type: 'spki', format: 'der' })).digest('hex') };
|
|
721
|
+
};
|
|
722
|
+
const protectedDirectory = directory => {
|
|
723
|
+
for (let current = directory; ; current = path.dirname(current)) {
|
|
724
|
+
const st = io.lstatSync(current);
|
|
725
|
+
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) fail('maintenance/rollback ancestry is not root-protected');
|
|
726
|
+
if (path.dirname(current) === current) break;
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
const rootInfo = io.lstatSync(ncRoot);
|
|
730
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail('source root is not a directory');
|
|
731
|
+
const loginMove = env.NC_SERVICE_MODE === 'user';
|
|
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');
|
|
737
|
+
if (!/^[a-z_][a-z0-9_-]*[$]?$/.test(oldUser || '') || oldUser === 'root' || oldUser === 'nodecommand') fail('select the existing ordinary account explicitly');
|
|
738
|
+
const account = command('/usr/bin/getent', ['passwd', oldUser]).trim().split(':');
|
|
739
|
+
if (account.length !== 7 || account[0] !== oldUser || !/^\d+$/.test(account[2]) || !/^\d+$/.test(account[3])) fail('account inventory is ambiguous');
|
|
740
|
+
const oldUid = Number(account[2]), oldGid = Number(account[3]), oldHome = account[5];
|
|
741
|
+
if (oldUid === 0 || oldGid === 0 || (!loginMove && !/\/(?:nologin|false)$/.test(account[6]))) fail('selected account is not a dedicated non-root identity');
|
|
742
|
+
if (command('/usr/bin/getent', ['passwd', 'nodecommand'], { optional: true }) !== null ||
|
|
743
|
+
command('/usr/bin/getent', ['group', 'nodecommand'], { optional: true }) !== null) fail('nodecommand already exists; never merge an unrelated identity');
|
|
744
|
+
if (loginMove && (ncRoot === '/opt/nc' || !ncRoot.startsWith(oldHome + '/') || rootInfo.uid !== oldUid)) fail('the selected user tree does not belong to the selected login identity');
|
|
745
|
+
if (!loginMove && rootInfo.uid !== 0) fail('legacy dedicated system root is not protected');
|
|
746
|
+
const keyFdParent = directoryFd(path.join(ncRoot, 'config'));
|
|
747
|
+
let keyFd, selectedPrivateKey;
|
|
748
|
+
try {
|
|
749
|
+
keyFd = io.openSync(`/proc/self/fd/${keyFdParent}/nc-device-key.pem`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
750
|
+
const st = io.fstatSync(keyFd);
|
|
751
|
+
if (!st.isFile() || st.uid !== oldUid || st.size > 65536) fail('device key is not owned by the selected ordinary identity');
|
|
752
|
+
selectedPrivateKey = crypto.createPrivateKey(io.readFileSync(keyFd, 'utf8'));
|
|
753
|
+
} finally { if (keyFd !== undefined) io.closeSync(keyFd); io.closeSync(keyFdParent); }
|
|
754
|
+
const originalIdentity = { client_id: env.NC_CLIENT_ID, server_url: env.NC_SERVER_URL,
|
|
755
|
+
device_key_id: crypto.createHash('sha256').update(crypto.createPublicKey(selectedPrivateKey).export({ type: 'spki', format: 'der' })).digest('hex') };
|
|
756
|
+
if (!loginMove) protectedDirectory(ncRoot);
|
|
757
|
+
protectedDirectory(path.dirname(home));
|
|
758
|
+
const unit = loginMove ? path.join(oldHome, '.config/systemd/user/nc-client.service') : systemUnit;
|
|
759
|
+
if (!loginMove) {
|
|
760
|
+
protectedDirectory(path.dirname(unit));
|
|
761
|
+
const st = io.lstatSync(unit);
|
|
762
|
+
if (!st.isFile() || st.isSymbolicLink() || st.uid !== 0 || st.nlink !== 1 || (st.mode & 0o022)) fail('selected system unit is not root-protected');
|
|
763
|
+
}
|
|
764
|
+
const unitText = read(unit);
|
|
765
|
+
if (!unitText.split(/\r?\n/).includes(`WorkingDirectory=${ncRoot}/app`) ||
|
|
766
|
+
(!loginMove && (!unitText.split(/\r?\n/).includes('User=_nc') || !unitText.split(/\r?\n/).includes('Group=_nc')))) fail('selected service does not match this root/account');
|
|
767
|
+
if (loginMove && exists(systemUnit)) {
|
|
768
|
+
// A held global instance is inventory only. Never stop, adopt or rewrite it.
|
|
769
|
+
if (read(systemUnit).includes(`WorkingDirectory=${ncRoot}/app`)) fail('both managers claim this selected tree; repair the ambiguity first');
|
|
770
|
+
}
|
|
771
|
+
if (!loginMove) {
|
|
772
|
+
const aliases = command('/usr/bin/getent', ['passwd']).trim().split('\n').filter(line => Number(line.split(':')[2]) === oldUid);
|
|
773
|
+
if (aliases.length !== 1 || aliases[0].split(':')[0] !== '_nc') fail('legacy UID is shared; explicit identity reconciliation is required');
|
|
774
|
+
if (/^\s*(?:AmbientCapabilities|SupplementaryGroups|CapabilityBoundingSet)\s*=\s*\S/m.test(unitText)) fail('legacy service has additional privilege channels; repair explicitly first');
|
|
775
|
+
const group = command('/usr/bin/getent', ['group', '_nc']).trim().split(':');
|
|
776
|
+
if (Number(group[2]) !== oldGid) fail('legacy primary group mismatch');
|
|
777
|
+
const groupAliases = command('/usr/bin/getent', ['group']).trim().split('\n').filter(line => Number(line.split(':')[2]) === oldGid);
|
|
778
|
+
if (groupAliases.length !== 1 || groupAliases[0].split(':')[0] !== '_nc') fail('legacy GID is shared; no identity rename performed');
|
|
779
|
+
const journal = command('/usr/bin/getent', ['group', 'systemd-journal'], { optional: true });
|
|
780
|
+
const allowed = [oldGid, ...(journal ? [Number(journal.trim().split(':')[2])] : [])];
|
|
781
|
+
if (command('/usr/bin/id', ['-G', '_nc']).trim().split(/\s+/).some(gid => !allowed.includes(Number(gid)))) fail('legacy account has unapproved supplementary authority');
|
|
782
|
+
}
|
|
783
|
+
const helperEdits = [];
|
|
784
|
+
if (!loginMove && exists('/etc/systemd/system/nc-privileged-helper.socket')) {
|
|
785
|
+
const helperService = read('/etc/systemd/system/nc-privileged-helper.service');
|
|
786
|
+
if (!helperService.includes(`${ncRoot}/libexec/privileged-helper/privileged-helper-linux-daemon.mjs`)) fail('helper resources belong to another or unprotected installation');
|
|
787
|
+
const helperConfigPath = path.join(ncRoot, 'libexec/privileged-helper/privileged-helper.json');
|
|
788
|
+
protectedDirectory(path.dirname(helperConfigPath));
|
|
789
|
+
const helperConfig = JSON.parse(read(helperConfigPath));
|
|
790
|
+
if (helperConfig.client_id !== hostId || helperConfig.service_uid !== oldUid || helperConfig.service_gid !== oldGid) fail('helper peer binding is not this selected identity');
|
|
791
|
+
for (const file of ['/etc/systemd/system/nc-privileged-helper.socket', '/etc/tmpfiles.d/nc-privileged-helper.conf']) {
|
|
792
|
+
if (exists(file)) {
|
|
793
|
+
protectedDirectory(path.dirname(file));
|
|
794
|
+
const text = read(file);
|
|
795
|
+
helperEdits.push({ file, text, replacement: text.replace(/^SocketGroup=_nc$/m, 'SocketGroup=nodecommand').replace(/^(d\s+\/run\/nc\s+\S+\s+root\s+)_nc(\s)/m, '$1nodecommand$2') });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const destination = loginMove ? path.join(home, 'nc') : ncRoot;
|
|
800
|
+
if (exists(home)) fail('restricted home/destination already occupied');
|
|
801
|
+
if (exists(unit + '.identity-backup')) fail('an old service recovery copy is already present');
|
|
802
|
+
if (loginMove && exists(envPath + '.identity-backup')) fail('an old configuration recovery copy is already present');
|
|
803
|
+
const runtimeMatch = unitText.match(/^ExecStart=(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))\s*$/m);
|
|
804
|
+
const oldNode = runtimeMatch?.[1] || runtimeMatch?.[2];
|
|
805
|
+
if (!oldNode || (runtimeMatch[3] || runtimeMatch[4]) !== `${ncRoot}/app/launcher.mjs` || !path.isAbsolute(oldNode)) fail('runtime launch cannot be preserved safely');
|
|
806
|
+
const node = loginMove && oldNode.startsWith(ncRoot + '/') ? destination + oldNode.slice(ncRoot.length) : oldNode;
|
|
807
|
+
if (loginMove && oldNode.startsWith(oldHome + '/') && !oldNode.startsWith(ncRoot + '/')) fail('runtime belongs to the former login home; select an isolated runtime before migration');
|
|
808
|
+
const sudo = run('/usr/bin/sudo', ['-n', '-l', '-U', oldUser], { encoding: 'utf8', shell: false, timeout: 10000,
|
|
809
|
+
env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
|
|
810
|
+
if (!loginMove && !sudo.error && (sudo.status === 0 || sudo.status !== 1)) fail('legacy account has sudo authority or its sudo rules cannot be verified');
|
|
811
|
+
if (!loginMove && sudo.error && sudo.error.code !== 'ENOENT') fail('legacy sudo authority is unknown');
|
|
812
|
+
const manager = (user, userId, userHome, args) => command('/usr/sbin/runuser', ['-u', user, '--', '/usr/bin/env', '-i',
|
|
813
|
+
'PATH=/usr/sbin:/usr/bin:/sbin:/bin', `HOME=${userHome}`, `XDG_RUNTIME_DIR=/run/user/${userId}`,
|
|
814
|
+
`DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${userId}/bus`, '/usr/bin/systemctl', '--user', ...args]);
|
|
815
|
+
const oldManager = args => loginMove ? manager(oldUser, oldUid, oldHome, args) : command('/usr/bin/systemctl', args);
|
|
816
|
+
oldManager(['is-active', 'nc-client.service']);
|
|
817
|
+
// Check all rollback tools and manager reachability BEFORE stopping anything.
|
|
818
|
+
for (const exe of loginMove ? ['/usr/sbin/useradd', '/usr/sbin/userdel', '/usr/sbin/groupadd', '/usr/sbin/groupdel'] : ['/usr/sbin/usermod', '/usr/sbin/groupmod']) command(exe, ['--help']);
|
|
819
|
+
if (loginMove) {
|
|
820
|
+
const before = command('/usr/bin/loginctl', ['show-user', oldUser, '--property=Linger', '--value']).trim();
|
|
821
|
+
if (!['yes', 'no'].includes(before)) fail('old linger inventory is unknown');
|
|
822
|
+
} else if (exists(`/var/lib/systemd/linger/${oldUser}`)) fail('legacy user-manager/linger requires selected reconciliation before rename');
|
|
823
|
+
if (await authorizeMaintenance({ ...originalIdentity, ncRoot, old_user: oldUser, uid: oldUid, gid: oldGid,
|
|
824
|
+
privateKey: selectedPrivateKey, hostToken: env.NC_CLIENT_TOKEN }) !== true) fail('selected target maintenance was not authorized; held installations remain untouched');
|
|
825
|
+
const recovery = loginMove ? home : path.join(ncRoot, 'libexec');
|
|
826
|
+
if (!loginMove) {
|
|
827
|
+
io.mkdirSync(recovery, { recursive: true, mode: 0o755 });
|
|
828
|
+
const st = io.lstatSync(recovery); if (st.uid !== 0 || st.isSymbolicLink() || (st.mode & 0o022)) fail('rollback directory is not protected');
|
|
829
|
+
} else io.mkdirSync(home, { mode: 0o750 });
|
|
830
|
+
const journalPath = path.join(recovery, '.identity-maintenance.json');
|
|
831
|
+
if (exists(journalPath)) fail('an earlier identity maintenance needs recovery first');
|
|
832
|
+
const recoveryRecord = { host_id: hostId, source: ncRoot, destination, old_user: oldUser,
|
|
833
|
+
old_home: oldHome, uid: oldUid, gid: oldGid, identity: originalIdentity, unit, original_unit: unitText, original_env: envText, helper_edits: helperEdits };
|
|
834
|
+
const recordRecovery = (record, initial = false) => {
|
|
835
|
+
const target = initial ? journalPath : journalPath + '.next';
|
|
836
|
+
const fd = io.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
837
|
+
try { io.writeFileSync(fd, JSON.stringify(record) + '\n'); io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
838
|
+
if (!initial) io.renameSync(target, journalPath);
|
|
839
|
+
const directory = directoryFd(recovery);
|
|
840
|
+
try { io.fsyncSync(directory); } finally { io.closeSync(directory); }
|
|
841
|
+
};
|
|
842
|
+
recordRecovery({ ...recoveryRecord, state: 'prepared' }, true);
|
|
843
|
+
let stopped = false, accountCreated = false, groupCreated = false, renamedUser = false, renamedGroup = false, newStarted = false, managerStarted = false, retiredUnit = false, retiredEnv = false, changedHome = false;
|
|
844
|
+
const newUnit = loginMove ? path.join(home, '.config/systemd/user/nc-client.service') : unit;
|
|
845
|
+
let newUid = oldUid, newGid = oldGid;
|
|
846
|
+
try {
|
|
847
|
+
stopped = true; oldManager(['stop', 'nc-client.service']);
|
|
848
|
+
if (JSON.stringify(identity(ncRoot)) !== JSON.stringify(originalIdentity)) fail('source identity changed during preflight');
|
|
849
|
+
if (loginMove) {
|
|
850
|
+
// Open every source component with O_NOFOLLOW; root never follows a
|
|
851
|
+
// login-user replacement/symlink into another home or credential file.
|
|
852
|
+
const copy = (sourceFd, target, relative = '') => {
|
|
853
|
+
io.mkdirSync(target, { mode: 0o700 });
|
|
854
|
+
for (const name of io.readdirSync(`/proc/self/fd/${sourceFd}`)) {
|
|
855
|
+
if (!relative && !['app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'install.sh', 'uninstall.sh'].includes(name)) continue;
|
|
856
|
+
const source = `/proc/self/fd/${sourceFd}/${name}`, dest = path.join(target, name);
|
|
857
|
+
const st = io.lstatSync(source);
|
|
858
|
+
if (st.isSymbolicLink()) {
|
|
859
|
+
const link = io.readlinkSync(source);
|
|
860
|
+
if (path.isAbsolute(link) || !path.resolve(ncRoot, relative, link).startsWith(ncRoot + '/')) fail('external source symlink; explicitly export the selected dependency first');
|
|
861
|
+
io.symlinkSync(link, dest); continue;
|
|
862
|
+
}
|
|
863
|
+
const fd = io.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
864
|
+
try {
|
|
865
|
+
const before = io.fstatSync(fd);
|
|
866
|
+
if (before.uid !== oldUid && !(before.uid === 0 && (before.mode & 0o004))) fail('unrelated private source ownership');
|
|
867
|
+
if (before.isDirectory()) copy(fd, dest, path.join(relative, name));
|
|
868
|
+
else if (before.isFile()) {
|
|
869
|
+
const output = io.openSync(dest, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, before.mode & 0o777);
|
|
870
|
+
try {
|
|
871
|
+
const buffer = Buffer.alloc(65536); let count;
|
|
872
|
+
while ((count = io.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
|
|
873
|
+
let offset = 0; while (offset < count) offset += io.writeSync(output, buffer, offset, count - offset);
|
|
874
|
+
}
|
|
875
|
+
} finally { io.closeSync(output); }
|
|
876
|
+
const after = io.fstatSync(fd);
|
|
877
|
+
if (before.size !== after.size || before.ctimeMs !== after.ctimeMs) fail('source changed while copying');
|
|
878
|
+
} else fail('special files cannot be relocated');
|
|
879
|
+
} finally { io.closeSync(fd); }
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
const sourceFd = directoryFd(ncRoot);
|
|
883
|
+
const pinned = io.fstatSync(sourceFd);
|
|
884
|
+
if (pinned.ino !== rootInfo.ino || pinned.dev !== rootInfo.dev) { io.closeSync(sourceFd); fail('selected source root was replaced'); }
|
|
885
|
+
try { copy(sourceFd, destination); } finally { io.closeSync(sourceFd); }
|
|
886
|
+
if (JSON.stringify(identity(destination)) !== JSON.stringify(originalIdentity)) fail('relocation identity mismatch');
|
|
887
|
+
command('/usr/sbin/groupadd', ['--system', 'nodecommand']); groupCreated = true;
|
|
888
|
+
command('/usr/sbin/useradd', ['--system', '--gid', 'nodecommand', '--home-dir', home, '--shell', '/usr/sbin/nologin', 'nodecommand']); accountCreated = true;
|
|
889
|
+
const created = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
|
|
890
|
+
newUid = Number(created[2]); newGid = Number(created[3]);
|
|
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');
|
|
898
|
+
const rewritten = { ...parseMaintenanceEnv(read(path.join(destination, 'config/nc-client.env'))), NC_SERVICE_MODE: 'user',
|
|
899
|
+
NC_ROOT: destination, NC_SERVICE_UNIT: 'nc-client.service', NC_OPERATOR_USER: 'nodecommand', NC_SERVICE_USER: 'nodecommand', NC_SERVICE_GROUP: 'nodecommand' };
|
|
900
|
+
for (const name of ['NC_ENV_FILE', 'NC_LOG_FILE', 'NC_DEVICE_KEY_FILE', 'NC_NODE_BIN']) {
|
|
901
|
+
if (rewritten[name]?.startsWith(ncRoot + '/')) rewritten[name] = destination + rewritten[name].slice(ncRoot.length);
|
|
902
|
+
}
|
|
903
|
+
// Keep server, host, token, device key, private runtime and policy roots.
|
|
904
|
+
write(path.join(destination, 'config/nc-client.env'), Object.entries(rewritten).map(([key, value]) => `${key}=${value}`).join('\n') + '\n', 0o600);
|
|
905
|
+
const own = dir => { for (const entry of io.readdirSync(dir)) { const file = path.join(dir, entry); const st = io.lstatSync(file);
|
|
906
|
+
if (st.isSymbolicLink()) { const actual = io.realpathSync(file); if (!actual.startsWith(destination + '/')) fail('relocated symlink escapes installation'); io.lchownSync(file, newUid, newGid); }
|
|
907
|
+
else { if (st.isDirectory()) own(file); io.chownSync(file, newUid, newGid); } } };
|
|
908
|
+
own(destination); io.chownSync(destination, newUid, newGid);
|
|
909
|
+
io.chownSync(home, 0, newGid);
|
|
910
|
+
io.mkdirSync(path.join(home, 'workspace'), { mode: 0o700 }); io.chownSync(path.join(home, 'workspace'), newUid, newGid);
|
|
911
|
+
for (const relative of ['.config', '.config/systemd', '.config/systemd/user', '.cache', '.local', '.local/share']) {
|
|
912
|
+
const directory = path.join(home, relative); io.mkdirSync(directory, { mode: 0o700 }); io.chownSync(directory, newUid, newGid);
|
|
913
|
+
}
|
|
914
|
+
io.writeFileSync(newUnit, `[Unit]\nDescription=Node Command Agent\nAfter=network-online.target\n[Service]\nType=simple\nWorkingDirectory=${destination}/app\nExecStart="${node}" "${destination}/app/launcher.mjs"\nEnvironmentFile=${destination}/config/nc-client.env\nEnvironment=HOME=${home}\nEnvironment=XDG_RUNTIME_DIR=/run/user/${newUid}\nEnvironment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${newUid}/bus\nUnsetEnvironment=SSH_AUTH_SOCK SSH_AGENT_PID SUDO_ASKPASS NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH\nNoNewPrivileges=yes\nRestart=always\nRestartSec=5\n[Install]\nWantedBy=default.target\n`, { mode: 0o600 });
|
|
915
|
+
io.chownSync(newUnit, newUid, newGid);
|
|
916
|
+
for (const socket of ['/var/run/docker.sock', '/run/podman/podman.sock', '/run/containerd/containerd.sock', '/var/lib/lxd/unix.socket', '/run/libvirt/libvirt-sock']) {
|
|
917
|
+
if (!exists(socket)) continue;
|
|
918
|
+
const access = run('/usr/sbin/runuser', ['-u', 'nodecommand', '--', '/usr/bin/test', '-w', socket], {
|
|
919
|
+
encoding: 'utf8', shell: false, timeout: 10000, env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin' } });
|
|
920
|
+
if (access.error || access.status !== 1) fail('restricted identity can access a root-equivalent IPC endpoint or its access cannot be verified');
|
|
921
|
+
}
|
|
922
|
+
command('/usr/bin/loginctl', ['enable-linger', 'nodecommand']);
|
|
923
|
+
managerStarted = true;
|
|
924
|
+
command('/usr/bin/systemctl', ['start', `user@${newUid}.service`]);
|
|
925
|
+
} else {
|
|
926
|
+
const processes = run('/usr/bin/ps', ['-u', String(oldUid), '-o', 'pid='], { encoding: 'utf8', shell: false, timeout: 10000,
|
|
927
|
+
env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
|
|
928
|
+
if (processes.error || ![0, 1].includes(processes.status) || String(processes.stdout || '').trim() || String(processes.stderr || '').trim()) fail('other legacy-account processes remain or inventory is unknown; no account rename performed');
|
|
929
|
+
command('/usr/sbin/usermod', ['--login', 'nodecommand', '_nc']); renamedUser = true;
|
|
930
|
+
command('/usr/sbin/groupmod', ['--new-name', 'nodecommand', '_nc']); renamedGroup = true;
|
|
931
|
+
const renamed = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
|
|
932
|
+
if (renamed[0] !== 'nodecommand' || Number(renamed[2]) !== oldUid || Number(renamed[3]) !== oldGid ||
|
|
933
|
+
Number(command('/usr/bin/getent', ['group', 'nodecommand']).trim().split(':')[2]) !== oldGid) fail('account rename did not preserve UID/GID');
|
|
934
|
+
io.mkdirSync(home, { mode: 0o750 }); changedHome = true; io.chownSync(home, 0, oldGid);
|
|
935
|
+
// Change only passwd home metadata; never move an arbitrary former
|
|
936
|
+
// account home or recursively change its files/permissions.
|
|
937
|
+
command('/usr/sbin/usermod', ['--home', home, 'nodecommand']);
|
|
938
|
+
const relocated = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
|
|
939
|
+
if (relocated[5] !== home || Number(relocated[2]) !== oldUid || Number(relocated[3]) !== oldGid) fail('new home metadata or stable UID/GID could not be verified');
|
|
940
|
+
for (const relative of ['workspace', '.config', '.config/systemd', '.config/systemd/user', '.cache', '.local', '.local/share']) {
|
|
941
|
+
const directory = path.join(home, relative); io.mkdirSync(directory, { mode: 0o700 }); io.chownSync(directory, oldUid, oldGid);
|
|
942
|
+
}
|
|
943
|
+
// Regenerate the ordinary service rather than retaining root-capable
|
|
944
|
+
// ExecStartPre/credential hooks or an old login environment.
|
|
945
|
+
write(unit, `[Unit]\nDescription=Node Command Agent\nAfter=network-online.target\n[Service]\nType=simple\nUser=nodecommand\nGroup=nodecommand\nWorkingDirectory=${ncRoot}/app\nEnvironmentFile=${envPath}\nEnvironment=HOME=${home}\nEnvironment=XDG_RUNTIME_DIR=/run/user/${oldUid}\nEnvironment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${oldUid}/bus\nUnsetEnvironment=SSH_AUTH_SOCK SSH_AGENT_PID SUDO_ASKPASS NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH\nExecStart="${node}" "${ncRoot}/app/launcher.mjs"\nNoNewPrivileges=yes\nCapabilityBoundingSet=\nAmbientCapabilities=\nRestart=always\nRestartSec=5\n[Install]\nWantedBy=multi-user.target\n`, 0o644);
|
|
946
|
+
write(envPath, envText.replace(/^(NC_SERVICE_USER|NC_SERVICE_GROUP|NC_OPERATOR_USER)=(?:"_nc"|'_nc'|_nc)$/gm, '$1=nodecommand'), 0o600);
|
|
947
|
+
for (const edit of helperEdits) write(edit.file, edit.replacement, 0o644);
|
|
948
|
+
command('/usr/bin/loginctl', ['enable-linger', 'nodecommand']);
|
|
949
|
+
managerStarted = true;
|
|
950
|
+
command('/usr/bin/systemctl', ['start', `user@${newUid}.service`]);
|
|
951
|
+
}
|
|
952
|
+
const newManager = args => loginMove ? manager('nodecommand', newUid, home, args) : command('/usr/bin/systemctl', args);
|
|
953
|
+
newManager(['daemon-reload']); newStarted = true; newManager(['enable', '--now', 'nc-client.service']);
|
|
954
|
+
newManager(['is-active', 'nc-client.service']);
|
|
955
|
+
const currentIdentity = identity(destination);
|
|
956
|
+
if (JSON.stringify(currentIdentity) !== JSON.stringify(originalIdentity) || await verifyReconnection({ ...currentIdentity, ncRoot: destination, service_user: 'nodecommand', uid: newUid, gid: newGid,
|
|
957
|
+
privateKey: selectedPrivateKey, hostToken: env.NC_CLIENT_TOKEN }) !== true) fail('same-host reconnection not independently confirmed');
|
|
958
|
+
if (loginMove) {
|
|
959
|
+
oldManager(['disable', 'nc-client.service']);
|
|
960
|
+
const retire = (file, text) => {
|
|
961
|
+
const parent = directoryFd(path.dirname(file)); let fd;
|
|
962
|
+
try {
|
|
963
|
+
fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}.identity-backup`, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
964
|
+
io.writeFileSync(fd, text); io.fsyncSync(fd);
|
|
965
|
+
io.unlinkSync(`/proc/self/fd/${parent}/${path.basename(file)}`);
|
|
966
|
+
} finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
|
|
967
|
+
};
|
|
968
|
+
retire(unit, unitText); retiredUnit = true;
|
|
969
|
+
retire(envPath, envText); retiredEnv = true;
|
|
970
|
+
}
|
|
971
|
+
recordRecovery({ ...recoveryRecord, state: 'verified', new_uid: newUid, new_gid: newGid });
|
|
972
|
+
return { ...currentIdentity, ncRoot: destination, service_user: 'nodecommand', uid: newUid, gid: newGid,
|
|
973
|
+
workspace: path.join(home, 'workspace'), helper_readiness: 'unavailable', existing_permission: 'preserved', rollback_journal: journalPath };
|
|
974
|
+
} catch (error) {
|
|
975
|
+
try {
|
|
976
|
+
if (newStarted) {
|
|
977
|
+
if (loginMove) manager('nodecommand', newUid, home, ['disable', '--now', 'nc-client.service']);
|
|
978
|
+
else command('/usr/bin/systemctl', ['stop', 'nc-client.service']);
|
|
979
|
+
}
|
|
980
|
+
if (loginMove) {
|
|
981
|
+
if (accountCreated) { command('/usr/bin/loginctl', ['disable-linger', 'nodecommand']); command('/usr/bin/systemctl', ['stop', `user@${newUid}.service`]); command('/usr/sbin/userdel', ['nodecommand']); }
|
|
982
|
+
if (groupCreated) command('/usr/sbin/groupdel', ['nodecommand']);
|
|
983
|
+
io.rmSync(home, { recursive: true });
|
|
984
|
+
if (retiredUnit) { write(unit, unitText, 0o600); io.chownSync(unit, oldUid, oldGid); io.unlinkSync(unit + '.identity-backup'); }
|
|
985
|
+
if (retiredEnv) { write(envPath, envText, 0o600); io.chownSync(envPath, oldUid, oldGid); io.unlinkSync(envPath + '.identity-backup'); }
|
|
986
|
+
} else {
|
|
987
|
+
if (managerStarted) { command('/usr/bin/loginctl', ['disable-linger', 'nodecommand']); command('/usr/bin/systemctl', ['stop', `user@${oldUid}.service`]); }
|
|
988
|
+
if (changedHome) { command('/usr/sbin/usermod', ['--home', oldHome, 'nodecommand']); io.rmSync(home, { recursive: true }); }
|
|
989
|
+
if (renamedGroup) command('/usr/sbin/groupmod', ['--new-name', '_nc', 'nodecommand']);
|
|
990
|
+
if (renamedUser) command('/usr/sbin/usermod', ['--login', '_nc', 'nodecommand']);
|
|
991
|
+
write(unit, unitText, 0o644); write(envPath, envText, 0o600);
|
|
992
|
+
for (const edit of helperEdits) write(edit.file, edit.text, 0o644);
|
|
993
|
+
command('/usr/bin/systemctl', ['daemon-reload']);
|
|
994
|
+
}
|
|
995
|
+
if (stopped) oldManager(['start', 'nc-client.service']);
|
|
996
|
+
if (!loginMove) io.rmSync(journalPath);
|
|
997
|
+
} catch { throw new Error('IDENTITY_MAINTENANCE_ROLLBACK_REQUIRED: preserve the protected recovery journal; do not rerun installation or replace credentials', { cause: error }); }
|
|
998
|
+
throw error;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
245
1002
|
/**
|
|
246
1003
|
* Uninstaller script living inside an installed tree. The scripts are
|
|
247
1004
|
* self-locating ($ROOT derived from their own path), so no root argument is
|
|
@@ -255,8 +1012,8 @@ export function uninstallerForRoot(root, platform = os.platform()) {
|
|
|
255
1012
|
}
|
|
256
1013
|
|
|
257
1014
|
/**
|
|
258
|
-
* Suggested-action line printed when an uninstaller fails. The
|
|
259
|
-
*
|
|
1015
|
+
* Suggested-action line printed when an uninstaller fails. The Unix text
|
|
1016
|
+
* requires trusted maintenance, never implicit sudo; win32 keeps the generic wording because
|
|
260
1017
|
* the .ps1 uninstaller already runs with the caller's own rights (no sudo
|
|
261
1018
|
* equivalent is attempted there).
|
|
262
1019
|
* @param {string} [platform]
|
|
@@ -264,31 +1021,28 @@ export function uninstallerForRoot(root, platform = os.platform()) {
|
|
|
264
1021
|
export function uninstallFailureSuggestion(platform = os.platform()) {
|
|
265
1022
|
const base = 'Re-run with administrator/root rights, then retry.';
|
|
266
1023
|
if (platform === 'win32') return `suggested_action: ${base}`;
|
|
267
|
-
return
|
|
1024
|
+
return 'suggested_action: Use explicitly trusted root maintenance for system removal. Do not sudo an ordinary-writable CLI or uninstall script; preserve the protected tree/journal until managed-work cleanup is verified.';
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** User-mode authority comes from the selected tree, not the caller's UID. */
|
|
1028
|
+
export function isUserInstallRoot(root) {
|
|
1029
|
+
try {
|
|
1030
|
+
return /^NC_SERVICE_MODE=user\r?$/m.test(fs.readFileSync(path.join(root, 'config', 'nc-client.env'), 'utf8').replace(/^\uFEFF/, ''));
|
|
1031
|
+
} catch {
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
268
1034
|
}
|
|
269
1035
|
|
|
1036
|
+
export const LINUX_PAIRED_UNINSTALL_ENTRY = '/opt/nc/libexec/privileged-helper/install/uninstall-linux.sh';
|
|
1037
|
+
|
|
270
1038
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* read it — plain `bash <script>` always fails there with status 126. When
|
|
276
|
-
* the caller is not root the script is therefore run via `sudo -E bash`,
|
|
277
|
-
* keeping `stdio: 'inherit'` so the sudo password prompt stays visible.
|
|
278
|
-
* When sudo itself is missing (ENOENT) the direct call is retried once so
|
|
279
|
-
* user-owned trees stay removable without sudo; any other sudo failure
|
|
280
|
-
* propagates unchanged and the caller reports the usual [UNINSTALL_FAILED]
|
|
281
|
-
* guidance — behavior never gets worse, it only starts working when sudo
|
|
282
|
-
* exists. Already-root callers run the script directly (no sudo).
|
|
283
|
-
*
|
|
284
|
-
* Security boundary: the caller (runRemove) only ever passes the
|
|
285
|
-
* uninstallerForRoot() path of a tree that already passed the
|
|
286
|
-
* isInstallRoot() check inside findInstallations() — elevation is never
|
|
287
|
-
* applied to an arbitrary path. Windows is untouched: the .ps1 uninstaller
|
|
288
|
-
* runs with the caller's own rights, so no elevation is attempted there.
|
|
1039
|
+
* Discovery markers are not execution trust. POSIX removal never elevates:
|
|
1040
|
+
* an ordinary caller may execute only its own user tree; root may execute
|
|
1041
|
+
* only a protected system script through a protected absolute interpreter.
|
|
1042
|
+
* Never forward startup hooks or loader/environment authority to that shell.
|
|
289
1043
|
* @param {string} scriptPath
|
|
290
1044
|
* @param {string|{ platform?: string, execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [platform]
|
|
291
|
-
* @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [options]
|
|
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]
|
|
292
1046
|
*/
|
|
293
1047
|
export function runUninstallerScript(scriptPath, platform = os.platform(), options = {}) {
|
|
294
1048
|
let opts = options || {};
|
|
@@ -298,8 +1052,40 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
|
|
|
298
1052
|
}
|
|
299
1053
|
const execFile = opts.execFile || child_process.execFileSync;
|
|
300
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();
|
|
301
1087
|
execFile(
|
|
302
|
-
'powershell
|
|
1088
|
+
windowsExecutablePath('powershell', env),
|
|
303
1089
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
|
|
304
1090
|
{ stdio: 'inherit' }
|
|
305
1091
|
);
|
|
@@ -309,7 +1095,7 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
|
|
|
309
1095
|
typeof opts.getuid === 'function'
|
|
310
1096
|
? opts.getuid
|
|
311
1097
|
: typeof process.getuid === 'function'
|
|
312
|
-
|
|
1098
|
+
? () => process.geteuid?.() ?? process.getuid()
|
|
313
1099
|
: null;
|
|
314
1100
|
let uid = null;
|
|
315
1101
|
try {
|
|
@@ -317,17 +1103,48 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
|
|
|
317
1103
|
} catch {
|
|
318
1104
|
uid = null;
|
|
319
1105
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
1106
|
+
const io = opts.io || fs;
|
|
1107
|
+
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' });
|
|
1108
|
+
const pairedEntry = platform === 'linux' && scriptPath === LINUX_PAIRED_UNINSTALL_ENTRY;
|
|
1109
|
+
if (!Number.isSafeInteger(uid) || uid < 0 || !path.isAbsolute(scriptPath) || path.resolve(scriptPath) !== scriptPath || (!pairedEntry && path.basename(scriptPath) !== 'uninstall.sh')) throw denied();
|
|
1110
|
+
const root = path.dirname(scriptPath);
|
|
1111
|
+
let userMode = false;
|
|
1112
|
+
if (!pairedEntry) try { userMode = /^NC_SERVICE_MODE=user\r?$/m.test(io.readFileSync(path.join(root, 'config/nc-client.env'), 'utf8').replace(/^\uFEFF/, '')); } catch {}
|
|
1113
|
+
const env = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'C', LC_ALL: 'C' };
|
|
1114
|
+
const protectedPath = (file, executable = false, checkAcl = true) => {
|
|
1115
|
+
if (io.realpathSync(file) !== file) throw denied();
|
|
1116
|
+
for (let current = file; ; current = path.dirname(current)) {
|
|
1117
|
+
const st = io.lstatSync(current);
|
|
1118
|
+
// Linux POSIX ACL write grants are bounded by the group-class mask.
|
|
1119
|
+
if (st.isSymbolicLink() || st.uid !== 0 || (pairedEntry && st.gid !== 0) || (st.mode & 0o022) ||
|
|
1120
|
+
(current === file ? !st.isFile() || st.nlink !== 1 || (executable && !(st.mode & 0o111)) : !st.isDirectory())) throw denied();
|
|
1121
|
+
if (platform === 'darwin' && checkAcl) {
|
|
1122
|
+
const ls = io.realpathSync('/bin/ls');
|
|
1123
|
+
protectedPath(ls, true, false);
|
|
1124
|
+
const acl = execFile(ls, ['-lde', current], { env, encoding: 'utf8' });
|
|
1125
|
+
if (typeof acl !== 'string' || !acl.trim() || acl.trim().split(/\r?\n/).length !== 1) throw denied();
|
|
1126
|
+
}
|
|
1127
|
+
if (current === path.dirname(current)) break;
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
324
1130
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
1131
|
+
if (pairedEntry && ((opts.getuid || process.getuid)?.() !== 0 || (opts.geteuid || process.geteuid)?.() !== 0)) throw denied();
|
|
1132
|
+
if (uid === 0) {
|
|
1133
|
+
if (userMode) throw denied();
|
|
1134
|
+
protectedPath(scriptPath);
|
|
1135
|
+
} else {
|
|
1136
|
+
if (!userMode || io.realpathSync(root) !== root) throw denied();
|
|
1137
|
+
for (const [file, directory] of [[root, true], [scriptPath, false]]) {
|
|
1138
|
+
const st = io.lstatSync(file);
|
|
1139
|
+
if (st.isSymbolicLink() || st.uid !== uid || (directory ? !st.isDirectory() : !st.isFile() || st.nlink !== 1)) throw denied();
|
|
1140
|
+
}
|
|
1141
|
+
env.HOME = os.userInfo().homedir;
|
|
1142
|
+
}
|
|
1143
|
+
const bash = io.realpathSync('/bin/bash');
|
|
1144
|
+
protectedPath(bash, true);
|
|
1145
|
+
execFile(bash, ['--noprofile', '--norc', '-p', '--', scriptPath], { stdio: 'inherit', env, cwd: '/' });
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
if (error?.status !== undefined || error?.code === 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw error;
|
|
1148
|
+
throw denied();
|
|
332
1149
|
}
|
|
333
1150
|
}
|