node-command 2.0.1 → 2.0.3
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 +541 -136
- package/lib/setup.mjs +730 -42
- package/package.json +11 -16
package/lib/setup.mjs
CHANGED
|
@@ -8,6 +8,42 @@ 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
|
+
}
|
|
11
47
|
|
|
12
48
|
/** Single-line notice printed when setup falls back to user mode. */
|
|
13
49
|
export const LIMITED_CAPS_WARNING =
|
|
@@ -134,10 +170,10 @@ export function candidateInstallRoots({ platform = os.platform(), env = process.
|
|
|
134
170
|
const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
|
|
135
171
|
roots.push(path.join(programData, 'NC'), path.join(localAppData, 'nc'));
|
|
136
172
|
} else if (platform === 'darwin') {
|
|
137
|
-
roots.push('/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
|
|
173
|
+
roots.push('/Library/Application Support/nc', '/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
|
|
138
174
|
} else {
|
|
139
175
|
const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
|
|
140
|
-
roots.push('/opt/nc', path.join(dataHome, 'nc'));
|
|
176
|
+
roots.push('/opt/nc', path.join(dataHome, 'nc'), '/var/lib/nodecommand/nc');
|
|
141
177
|
}
|
|
142
178
|
return [...new Set(roots)];
|
|
143
179
|
}
|
|
@@ -158,20 +194,119 @@ export function isInstallRoot(root, existsSync = fs.existsSync) {
|
|
|
158
194
|
}
|
|
159
195
|
}
|
|
160
196
|
|
|
197
|
+
/** Read-only recognition of the fixed protected single/dual-root producer.
|
|
198
|
+
* Protected records establish discovery, never permission to execute a tree.
|
|
199
|
+
*/
|
|
200
|
+
function linuxPairedInstallation(io = fs) {
|
|
201
|
+
io = { ...fs, ...io };
|
|
202
|
+
const helperRoot = '/opt/nc', pairedRoot = '/var/lib/nodecommand/nc';
|
|
203
|
+
const app = helperRoot + '/libexec/privileged-helper';
|
|
204
|
+
const lstat = io.lstatSync || fs.lstatSync;
|
|
205
|
+
const present = file => {
|
|
206
|
+
try { lstat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
207
|
+
};
|
|
208
|
+
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' }); };
|
|
209
|
+
try {
|
|
210
|
+
if (!present(app)) {
|
|
211
|
+
if (present(helperRoot + '/libexec/nc-linux-peercred')) fail();
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const check = (file, directory = false, protectedPath = true) => {
|
|
215
|
+
if (io.realpathSync(file) !== file) fail();
|
|
216
|
+
for (let current = file; ; current = path.dirname(current)) {
|
|
217
|
+
const st = lstat(current);
|
|
218
|
+
if (io.realpathSync(current) !== current || st.isSymbolicLink() || (protectedPath && (st.uid !== 0 || st.gid !== 0 || (st.mode & 0o022))) ||
|
|
219
|
+
(current === file && !directory ? !st.isFile() || st.nlink !== 1 : !st.isDirectory())) fail();
|
|
220
|
+
if (current === path.dirname(current)) return;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const read = (file, protectedPath = true, mode = 0o600) => {
|
|
224
|
+
check(file, false, protectedPath);
|
|
225
|
+
const fd = io.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
226
|
+
try {
|
|
227
|
+
const before = io.fstatSync(fd), named = lstat(file);
|
|
228
|
+
if (!before.isFile() || before.nlink !== 1 || before.dev !== named.dev || before.ino !== named.ino ||
|
|
229
|
+
!Number.isSafeInteger(before.size) || before.size <= 0 || before.size > 65536 ||
|
|
230
|
+
(protectedPath && (before.uid !== 0 || before.gid !== 0 || (before.mode & 0o7777) !== mode))) fail();
|
|
231
|
+
const text = io.readFileSync(fd, 'utf8'), after = io.fstatSync(fd);
|
|
232
|
+
if (Buffer.byteLength(text) !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) fail();
|
|
233
|
+
return text;
|
|
234
|
+
} finally { io.closeSync(fd); }
|
|
235
|
+
};
|
|
236
|
+
check(app, true);
|
|
237
|
+
if (io.readdirSync(helperRoot + '/libexec').sort().join('\n') !== 'nc-linux-peercred\nprivileged-helper') fail();
|
|
238
|
+
check(helperRoot + '/libexec/nc-linux-peercred');
|
|
239
|
+
const bootstrap = JSON.parse(read(app + '/helper-bootstrap.json'));
|
|
240
|
+
const selection = JSON.parse(read('/var/lib/nc-lifecycle/selected.json'));
|
|
241
|
+
const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value) &&
|
|
242
|
+
Object.keys(value).length === fields.length && fields.every(field => Object.hasOwn(value, field));
|
|
243
|
+
const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value);
|
|
244
|
+
const controlPlaneId = value => typeof value === 'string' && value.length > 0 && value.length <= 256 &&
|
|
245
|
+
value.trim() === value && !/[\x00-\x1f\x7f]/.test(value);
|
|
246
|
+
const canonical = value => typeof value === 'string' && path.isAbsolute(value) && path.resolve(value) === value && !/[\x00-\x1f\x7f]/.test(value);
|
|
247
|
+
if (!exact(bootstrap, ['client_id', 'control_plane_id', 'tenant_id', 'root_consent', 'ack_private_key_file', 'helper_key_id',
|
|
248
|
+
'helper_key_binding_id', 'binding_state', 'node_bin', 'ordinary_root', 'ordinary_service_scope', 'lifecycle_custody_state']) ||
|
|
249
|
+
!/^nc_[0-9a-f]{64}$/.test(bootstrap.client_id) || !controlPlaneId(bootstrap.control_plane_id) || !id(bootstrap.tenant_id) ||
|
|
250
|
+
!id(bootstrap.helper_key_id) || bootstrap.root_consent !== true || ![helperRoot, pairedRoot].includes(bootstrap.ordinary_root) || bootstrap.lifecycle_custody_state !== 'prepared' ||
|
|
251
|
+
!['system', 'user'].includes(bootstrap.ordinary_service_scope) || !canonical(bootstrap.node_bin) ||
|
|
252
|
+
bootstrap.ack_private_key_file !== app + '/privileged-helper-ack-key.pem' || bootstrap.helper_key_binding_id !== null || bootstrap.binding_state !== 'pending') fail();
|
|
253
|
+
const ncRoot = bootstrap.ordinary_root;
|
|
254
|
+
if (ncRoot === helperRoot) {
|
|
255
|
+
const ordinaryEntries = ['app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'libexec', 'uninstall.sh', 'install-linux.sh'];
|
|
256
|
+
if (present(pairedRoot) || !io.readdirSync(ncRoot).every(entry => ordinaryEntries.includes(entry))) fail();
|
|
257
|
+
} else if (io.readdirSync(helperRoot).sort().join('\n') !== 'libexec') fail();
|
|
258
|
+
if (!exact(selection, ['version', 'control_plane_id', 'tenant_id', 'client_id', 'helper_key_binding_id', 'helper_key_id',
|
|
259
|
+
'installation_root', 'ordinary_uid', 'ordinary_service_scope', 'ordinary_service_unit', 'artifacts', 'bundle']) || selection.version !== 1 ||
|
|
260
|
+
['control_plane_id', 'tenant_id', 'client_id', 'helper_key_id'].some(field => selection[field] !== bootstrap[field]) ||
|
|
261
|
+
!id(selection.helper_key_binding_id) || selection.installation_root !== ncRoot || selection.ordinary_service_scope !== bootstrap.ordinary_service_scope ||
|
|
262
|
+
selection.ordinary_service_unit !== 'nc-client.service' || !Number.isSafeInteger(selection.ordinary_uid) || selection.ordinary_uid <= 0 || selection.ordinary_uid > 0xfffffffe) fail();
|
|
263
|
+
const serviceFile = '/etc/systemd/' + selection.ordinary_service_scope + '/nc-client.service';
|
|
264
|
+
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();
|
|
265
|
+
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'];
|
|
266
|
+
const validFile = (file, name, mode) => exact(file, ['path', 'sha256', 'size', 'mode']) && file.path === name && file.mode === mode &&
|
|
267
|
+
typeof file.sha256 === 'string' && /^[0-9a-f]{64}$/.test(file.sha256) && Number.isSafeInteger(file.size) && file.size > 0 && file.size <= 256 * 1024 * 1024;
|
|
268
|
+
if (!Array.isArray(selection.artifacts) || selection.artifacts.length !== artifacts.length ||
|
|
269
|
+
!selection.artifacts.every((file, index) => validFile(file, artifacts[index], 0o644)) ||
|
|
270
|
+
!Array.isArray(selection.bundle) || selection.bundle.length !== bundle.length ||
|
|
271
|
+
!selection.bundle.every((file, index) => validFile(file, bundle[index], ['node', 'nc-linux-managed-work'].includes(bundle[index]) ? 0o755 : 0o644))) fail();
|
|
272
|
+
const serviceArtifact = selection.artifacts.find(file => file.path === serviceFile);
|
|
273
|
+
const unit = read(serviceFile, true, 0o644);
|
|
274
|
+
const directories = [...unit.matchAll(/^\s*WorkingDirectory\s*=\s*(.+?)\s*$/gm)];
|
|
275
|
+
const launches = [...unit.matchAll(/^\s*ExecStart\s*=\s*(.+?)\s*$/gm)];
|
|
276
|
+
const launch = launches[0]?.[1].match(/^(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))$/);
|
|
277
|
+
if (directories.length !== 1 || ![ncRoot, ncRoot + '/app'].includes(directories[0][1]) || launches.length !== 1 ||
|
|
278
|
+
!launch || !canonical(launch[1] || launch[2]) || (launch[3] || launch[4]) !== ncRoot + '/app/launcher.mjs' ||
|
|
279
|
+
Buffer.byteLength(unit) !== serviceArtifact.size || crypto.createHash('sha256').update(unit).digest('hex') !== serviceArtifact.sha256) fail();
|
|
280
|
+
check(path.dirname(ncRoot), true);
|
|
281
|
+
check(ncRoot, true, ncRoot === helperRoot);
|
|
282
|
+
if (![0, selection.ordinary_uid].includes(lstat(ncRoot).uid)) fail();
|
|
283
|
+
check(ncRoot + '/config', true, false);
|
|
284
|
+
if (lstat(ncRoot + '/config').uid !== selection.ordinary_uid) fail();
|
|
285
|
+
const content = read(ncRoot + '/config/nc-client.env', false);
|
|
286
|
+
const ids = content.split(/\r?\n/).filter(line => /^\s*NC_CLIENT_ID\s*=/.test(line));
|
|
287
|
+
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*$/);
|
|
288
|
+
if (ids.length !== 1 || !clientId || (clientId[1] || clientId[2] || clientId[3]) !== bootstrap.client_id) fail();
|
|
289
|
+
check(ncRoot + '/app/launcher.mjs', false, false);
|
|
290
|
+
return { ncRoot, helperRoot, clientId: bootstrap.client_id, ordinaryUid: selection.ordinary_uid,
|
|
291
|
+
serviceScope: selection.ordinary_service_scope, serviceFile, serviceArtifact };
|
|
292
|
+
} catch { fail(); }
|
|
293
|
+
}
|
|
294
|
+
|
|
161
295
|
/**
|
|
162
|
-
*
|
|
296
|
+
* Install-tree probe. A root-owned system tree (0750/0700) is
|
|
163
297
|
* unreadable to a non-root caller: a plain boolean would report "nothing to
|
|
164
298
|
* remove" for a machine-wide install that clearly exists, and the sudo path
|
|
165
299
|
* would never be reached. Distinguishes:
|
|
166
300
|
* - 'install' markers readable → normal removal path
|
|
167
301
|
* - 'absent' root directory does not exist
|
|
168
302
|
* - 'foreign' root exists and is traversable but carries no markers
|
|
303
|
+
* - 'paired-helper' protected metadata binds this helper to one ordinary root
|
|
169
304
|
* - 'unreadable' root exists but markers cannot be stat'ed (EACCES/EPERM)
|
|
170
305
|
* → needs elevation; the uninstaller's own safety guard
|
|
171
306
|
* still refuses non-NC trees when run under sudo.
|
|
172
307
|
* @param {string} root
|
|
173
308
|
* @param {{ statSync?: (p: string) => { isDirectory(): boolean }, accessSync?: (p: string, mode?: number) => void }} [io]
|
|
174
|
-
* @returns {'install'|'absent'|'foreign'|'unreadable'}
|
|
309
|
+
* @returns {'install'|'absent'|'foreign'|'unreadable'|'paired-helper'}
|
|
175
310
|
*/
|
|
176
311
|
export function probeInstallRoot(root, io = {}) {
|
|
177
312
|
const statSync = io.statSync || fs.statSync;
|
|
@@ -190,25 +325,35 @@ export function probeInstallRoot(root, io = {}) {
|
|
|
190
325
|
if (err && (err.code === 'EACCES' || err.code === 'EPERM')) return 'unreadable';
|
|
191
326
|
}
|
|
192
327
|
}
|
|
328
|
+
if (os.platform() === 'linux' && root === '/opt/nc' && linuxPairedInstallation(io)) return 'paired-helper';
|
|
193
329
|
return 'foreign';
|
|
194
330
|
}
|
|
195
331
|
|
|
196
332
|
/**
|
|
197
333
|
* Removal targets split by accessibility. `install` roots are removed by the
|
|
198
334
|
* local flow; `unreadable` roots (system installs seen from a non-root shell)
|
|
199
|
-
*
|
|
335
|
+
* report explicit trusted-maintenance requirements instead of being
|
|
200
336
|
* silently reported as "nothing to remove".
|
|
201
337
|
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
|
|
202
|
-
* @returns {{ install: string[], unreadable: string[] }}
|
|
338
|
+
* @returns {{ install: string[], unreadable: string[], paired?: { ncRoot: string, helperRoot: string, clientId: string }[] }}
|
|
203
339
|
*/
|
|
204
340
|
export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
205
341
|
const install = [];
|
|
206
342
|
const unreadable = [];
|
|
207
|
-
|
|
343
|
+
const pair = (rest.platform || os.platform()) === 'linux' ? linuxPairedInstallation(io) : null;
|
|
344
|
+
const env = rest.env || process.env;
|
|
345
|
+
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.');
|
|
346
|
+
const roots = candidateInstallRoots(pair ? { ...rest, env: { ...env, NC_ROOT: undefined } } : rest);
|
|
347
|
+
for (const root of pair ? [...new Set([...roots, pair.ncRoot])] : roots) {
|
|
348
|
+
if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
|
|
208
349
|
const status = probeInstallRoot(root, io);
|
|
209
350
|
if (status === 'install') install.push(root);
|
|
210
351
|
else if (status === 'unreadable') unreadable.push(root);
|
|
211
352
|
}
|
|
353
|
+
if (pair) {
|
|
354
|
+
if (install.length !== 1 || install[0] !== pair.ncRoot || unreadable.length) throw new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: conflicting paired installation inventory; preserve all roots.');
|
|
355
|
+
return { install, unreadable, paired: [pair] };
|
|
356
|
+
}
|
|
212
357
|
return { install, unreadable };
|
|
213
358
|
}
|
|
214
359
|
|
|
@@ -217,6 +362,7 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
|
217
362
|
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, existsSync?: (p: string) => boolean }} [options]
|
|
218
363
|
*/
|
|
219
364
|
export function findInstallations({ existsSync = fs.existsSync, ...rest } = {}) {
|
|
365
|
+
if ((rest.platform || os.platform()) === 'linux' && linuxPairedInstallation(rest.io || fs)) return findRemovalTargets(rest).install;
|
|
220
366
|
return candidateInstallRoots(rest).filter((r) => isInstallRoot(r, existsSync));
|
|
221
367
|
}
|
|
222
368
|
|
|
@@ -236,12 +382,526 @@ export function getNcRootForMode(mode, { platform = os.platform(), env = process
|
|
|
236
382
|
const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
|
|
237
383
|
return path.join(localAppData, 'nc');
|
|
238
384
|
}
|
|
239
|
-
if (mode === 'system') return '/opt/nc';
|
|
385
|
+
if (mode === 'system') return platform === 'darwin' ? '/Library/Application Support/nc' : '/opt/nc';
|
|
240
386
|
if (platform === 'darwin') return path.join(homedir, 'Library', 'Application Support', 'nc');
|
|
241
387
|
const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
|
|
242
388
|
return path.join(dataHome, 'nc');
|
|
243
389
|
}
|
|
244
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Resolve setup state before enrollment. Markers can prevent setup
|
|
393
|
+
* or preserve a root; they never authorize elevation or another user's mode.
|
|
394
|
+
* An explicit override does not hide an existing canonical tree or service.
|
|
395
|
+
* @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]
|
|
396
|
+
* @returns {{ mode: 'system'|'user', ncRoot: string, existing: boolean, clientId?: string, protectedInstallation?: { ordinaryUid: number, serviceScope: string }, rootManagedPair?: { ordinaryUid: number, serviceScope: string } }}
|
|
397
|
+
*/
|
|
398
|
+
export function setupTarget({ platform = os.platform(), mode = decideInstallMode({ platform }), env = process.env, homedir = os.homedir(), uid = process.getuid?.(), io = fs, run = child_process.spawnSync } = {}) {
|
|
399
|
+
const stop = (reason) => { throw new Error(`${platform === 'darwin' ? 'macOS' : platform} setup stopped: ${reason}`); };
|
|
400
|
+
if (platform !== 'win32' && (!Number.isInteger(uid) || uid < 0)) stop('cannot verify the invoking user.');
|
|
401
|
+
const canonicalEnv = { ...env };
|
|
402
|
+
delete canonicalEnv.NC_ROOT;
|
|
403
|
+
const systemRoot = path.resolve(getNcRootForMode('system', { platform, env: canonicalEnv, homedir }));
|
|
404
|
+
const legacySystemRoot = platform === 'darwin' ? '/opt/nc' : null;
|
|
405
|
+
const userRoot = path.resolve(getNcRootForMode('user', { platform, env: canonicalEnv, homedir }));
|
|
406
|
+
if (env.NC_ROOT && !path.isAbsolute(env.NC_ROOT)) stop('NC_ROOT must be an absolute existing or new installation path.');
|
|
407
|
+
const explicitRoot = env.NC_ROOT ? path.resolve(env.NC_ROOT) : null;
|
|
408
|
+
const roots = [...new Set([systemRoot, legacySystemRoot, userRoot, ...(platform === 'linux' ? ['/var/lib/nodecommand/nc'] : []), explicitRoot].filter(Boolean))];
|
|
409
|
+
const pair = platform === 'linux' ? linuxPairedInstallation(io) : null;
|
|
410
|
+
const stat = (file) => {
|
|
411
|
+
try { return io.lstatSync(file); } catch (err) {
|
|
412
|
+
if (err?.code === 'ENOENT') return null;
|
|
413
|
+
stop(`existing installation state is unreadable or unverifiable at ${file}.`);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const read = (file) => {
|
|
417
|
+
const info = stat(file);
|
|
418
|
+
if (!info) return null;
|
|
419
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536) stop('installation metadata is ambiguous.');
|
|
420
|
+
try {
|
|
421
|
+
const content = io.readFileSync(file, { encoding: 'utf8', flag: fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW });
|
|
422
|
+
if (Buffer.byteLength(content) > 65536) stop('installation metadata exceeds the supported bound.');
|
|
423
|
+
return content;
|
|
424
|
+
} catch { stop('existing installation metadata cannot be read safely.'); }
|
|
425
|
+
};
|
|
426
|
+
const installs = [];
|
|
427
|
+
for (const root of roots) {
|
|
428
|
+
if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
|
|
429
|
+
const info = stat(root);
|
|
430
|
+
if (!info) continue;
|
|
431
|
+
if (!info.isDirectory() || info.isSymbolicLink()) stop('an installation root is not a regular directory.');
|
|
432
|
+
const config = stat(path.join(root, 'config'));
|
|
433
|
+
if (!config?.isDirectory() || config.isSymbolicLink()) stop('an existing root has no verifiable NC configuration.');
|
|
434
|
+
const content = read(path.join(root, 'config', 'nc-client.env'));
|
|
435
|
+
if (content === null) stop('an existing root has no verifiable NC configuration.');
|
|
436
|
+
const entries = content.split(/\r?\n/).filter(line => /^\s*NC_SERVICE_MODE\s*=/.test(line));
|
|
437
|
+
const match = entries[0]?.match(/^\s*NC_SERVICE_MODE\s*=\s*(?:"(user|system)"|'(user|system)'|(user|system))\s*$/);
|
|
438
|
+
if (entries.length > 1 || (entries.length && !match)) stop('stored installation mode is ambiguous.');
|
|
439
|
+
const storedMode = match ? match[1] || match[2] || match[3] : null;
|
|
440
|
+
// Legacy canonical trees retain their installer ownership/layout. Linux
|
|
441
|
+
// custom trees can also identify their mode by owner; other custom trees
|
|
442
|
+
// require the persisted mode rather than guessing from current privilege.
|
|
443
|
+
const detectedMode = storedMode || (root === pair?.ncRoot ? info.uid === 0 ? 'system' : info.uid === uid && uid !== 0 ? 'user' : null : platform === 'win32'
|
|
444
|
+
? root === userRoot ? 'user' : root === systemRoot ? 'system' : null
|
|
445
|
+
: root === userRoot && info.uid === uid && uid !== 0 ? 'user' : (root === systemRoot || root === legacySystemRoot) && info.uid === 0 ? 'system'
|
|
446
|
+
: platform === 'linux' && root === explicitRoot ? info.uid === 0 ? 'system' : info.uid === uid ? 'user' : null : null);
|
|
447
|
+
// Root may verify this independently selected restricted pair, not adopt
|
|
448
|
+
// its user identity or execute a user installer. Management mode stays system.
|
|
449
|
+
const rootManagedPair = root === pair?.ncRoot && uid === 0 && process.getuid?.() === 0 && process.geteuid?.() === 0 && mode === 'system' &&
|
|
450
|
+
storedMode === 'user' && pair.serviceScope === 'user' && info.uid === pair.ordinaryUid && config.uid === pair.ordinaryUid;
|
|
451
|
+
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}.`);
|
|
452
|
+
if (((root === systemRoot || root === legacySystemRoot) && detectedMode !== 'system') || (root === userRoot && detectedMode !== 'user')) stop(`stored mode conflicts with the installation root ${root}.`);
|
|
453
|
+
for (const name of ['NC_CLIENT_ID', 'NC_CLIENT_TOKEN', 'NC_SERVER_URL']) {
|
|
454
|
+
if (content.split(/\r?\n/).filter(line => new RegExp(`^\\s*${name}\\s*=`).test(line)).length > 1) stop(`duplicate host metadata at ${root}.`);
|
|
455
|
+
}
|
|
456
|
+
installs.push({ mode: detectedMode, ncRoot: root, rootManagedPair });
|
|
457
|
+
}
|
|
458
|
+
const services = [];
|
|
459
|
+
for (const [file, serviceMode, owner] of platform === 'darwin' ? [
|
|
460
|
+
['/Library/LaunchDaemons/com.nc.client.plist', 'system', 0],
|
|
461
|
+
[path.join(homedir, 'Library', 'LaunchAgents', 'com.nc.client.plist'), 'user', uid]
|
|
462
|
+
] : []) {
|
|
463
|
+
const info = stat(file);
|
|
464
|
+
if (!info) continue;
|
|
465
|
+
const content = read(file);
|
|
466
|
+
const labels = [...content.matchAll(/<key>Label<\/key>\s*<string>([^<]*)<\/string>/g)];
|
|
467
|
+
const directories = [...content.matchAll(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/g)];
|
|
468
|
+
if (info.uid !== owner || labels.length !== 1 || labels[0][1] !== 'com.nc.client' || directories.length !== 1) stop('existing service metadata is ambiguous.');
|
|
469
|
+
const directory = directories[0][1].replace(/&(amp|lt|gt|quot|apos);/g, (_, entity) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[entity]);
|
|
470
|
+
if (!path.isAbsolute(directory) || path.basename(directory) !== 'app') stop('existing service root cannot be verified.');
|
|
471
|
+
const serviceRoot = path.dirname(path.resolve(directory));
|
|
472
|
+
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.');
|
|
473
|
+
services.push(serviceRoot);
|
|
474
|
+
}
|
|
475
|
+
if (platform === 'linux') {
|
|
476
|
+
for (const [file, serviceMode] of [
|
|
477
|
+
['/etc/systemd/system/nc-client.service', 'system'],
|
|
478
|
+
['/etc/systemd/user/nc-client.service', 'user'],
|
|
479
|
+
[path.join(homedir, '.config/systemd/user/nc-client.service'), 'user'],
|
|
480
|
+
...(env.XDG_CONFIG_HOME ? [[path.join(env.XDG_CONFIG_HOME, 'systemd/user/nc-client.service'), 'user']] : [])
|
|
481
|
+
]) {
|
|
482
|
+
const content = read(file);
|
|
483
|
+
if (content === null) continue;
|
|
484
|
+
if (pair && file !== pair.serviceFile) stop(`another ordinary service exists at ${file}; preserve the paired installation through explicit Root maintenance.`);
|
|
485
|
+
const directories = [...content.matchAll(/^\s*WorkingDirectory\s*=\s*(.+?)\s*$/gm)];
|
|
486
|
+
const directory = directories[0]?.[1];
|
|
487
|
+
if (directories.length !== 1 || !path.isAbsolute(directory)) stop(`existing service metadata is ambiguous at ${file}.`);
|
|
488
|
+
const pairedService = pair && file === pair.serviceFile;
|
|
489
|
+
const serviceRoot = pairedService && directory === pair.ncRoot ? pair.ncRoot : path.basename(directory) === 'app' ? path.dirname(path.resolve(directory)) : null;
|
|
490
|
+
if (pairedService) {
|
|
491
|
+
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.`);
|
|
492
|
+
}
|
|
493
|
+
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.`);
|
|
494
|
+
services.push(serviceRoot);
|
|
495
|
+
}
|
|
496
|
+
if (pair && !services.includes(pair.ncRoot)) stop('paired ordinary service is missing; explicit Root maintenance is required.');
|
|
497
|
+
}
|
|
498
|
+
if (platform === 'win32') {
|
|
499
|
+
// Discovery only: never start/stop tasks or services to identify a target.
|
|
500
|
+
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
|
+
let state;
|
|
502
|
+
try {
|
|
503
|
+
const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 10000, maxBuffer: 65536, windowsHide: true });
|
|
504
|
+
if (result.status !== 0) stop('Windows service/task state is unreadable; inspect NCClient and the NC Client task through administrator maintenance.');
|
|
505
|
+
state = JSON.parse(String(result.stdout));
|
|
506
|
+
if (!Array.isArray(state.services) || !Array.isArray(state.tasks)) throw new Error('invalid service state');
|
|
507
|
+
} catch { stop('Windows service/task state cannot be verified; inspect NCClient and the NC Client task through administrator maintenance.'); }
|
|
508
|
+
for (const service of state.services) {
|
|
509
|
+
const executable = service.PathName?.match(/^"([^"]+\\service\\nc-service\.exe)"\s/i)?.[1];
|
|
510
|
+
const root = executable && path.resolve(path.dirname(path.dirname(executable)));
|
|
511
|
+
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.');
|
|
512
|
+
services.push(root);
|
|
513
|
+
}
|
|
514
|
+
for (const task of state.tasks) {
|
|
515
|
+
const root = typeof task.WorkingDirectory === 'string' && path.isAbsolute(task.WorkingDirectory) ? path.resolve(task.WorkingDirectory) : null;
|
|
516
|
+
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.');
|
|
517
|
+
services.push(root);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
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.`);
|
|
521
|
+
const existing = installs[0];
|
|
522
|
+
if (existing && explicitRoot && explicitRoot !== existing.ncRoot) stop('NC_ROOT would replace an existing installation root.');
|
|
523
|
+
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.');
|
|
524
|
+
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.');
|
|
525
|
+
return { mode, ncRoot: existing?.ncRoot || explicitRoot || (mode === 'system' ? systemRoot : userRoot), existing: !!existing,
|
|
526
|
+
...(pair ? { clientId: pair.clientId, protectedInstallation: { ordinaryUid: pair.ordinaryUid, serviceScope: pair.serviceScope } } : {}),
|
|
527
|
+
...(existing?.rootManagedPair ? { rootManagedPair: { ordinaryUid: pair.ordinaryUid, serviceScope: pair.serviceScope } } : {}) };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Preserve the macOS entry used by existing callers and fixtures. */
|
|
531
|
+
export function macosSetupTarget({ uid = process.getuid?.(), ...options } = {}) {
|
|
532
|
+
return setupTarget({ ...options, platform: 'darwin', uid, mode: uid === 0 ? 'system' : 'user' });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Parse installation metadata as bounded data, NEVER as a root shell script. */
|
|
536
|
+
export function parseMaintenanceEnv(text) {
|
|
537
|
+
if (Buffer.byteLength(text) > 65536) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
|
|
538
|
+
const result = Object.create(null);
|
|
539
|
+
for (const line of text.split(/\r?\n/)) {
|
|
540
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
541
|
+
const match = line.match(/^(NC_[A-Z0-9_]+)=(.*)$/);
|
|
542
|
+
if (!match || Object.hasOwn(result, match[1]) || /[\x00-\x1f]/.test(match[2])) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
|
|
543
|
+
let value = match[2];
|
|
544
|
+
if (/^["']/.test(value)) {
|
|
545
|
+
if (value.at(-1) !== value[0]) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
|
|
546
|
+
value = value.slice(1, -1);
|
|
547
|
+
}
|
|
548
|
+
result[match[1]] = value;
|
|
549
|
+
}
|
|
550
|
+
if (!/^nc_[0-9a-f]{64}$/.test(result.NC_CLIENT_ID || '') || !result.NC_CLIENT_TOKEN ||
|
|
551
|
+
new URL(result.NC_SERVER_URL).protocol !== 'https:') throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Import only an already provisioned root-protected producer. Neither npx
|
|
556
|
+
* ordinary cache code nor an agent-writable helper module may hold ACK keys.
|
|
557
|
+
*/
|
|
558
|
+
export async function loadProtectedHelperProducer(root) {
|
|
559
|
+
if (process.geteuid?.() !== 0 || !path.isAbsolute(root) || process.env.NODE_OPTIONS || process.env.NODE_PATH)
|
|
560
|
+
throw new Error('PRIVILEGED_HELPER_ROOT_MAINTENANCE_REQUIRED');
|
|
561
|
+
const check = file => {
|
|
562
|
+
for (let p = file; ; p = path.dirname(p)) {
|
|
563
|
+
const st = fs.lstatSync(p);
|
|
564
|
+
if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) throw new Error('PRIVILEGED_HELPER_UNPROTECTED_PATH');
|
|
565
|
+
if (path.dirname(p) === p) break;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
check(fs.realpathSync(process.execPath)); check(fileURLToPath(import.meta.url)); check(fs.realpathSync(process.argv[1]));
|
|
569
|
+
const app = path.join(root, 'libexec/privileged-helper');
|
|
570
|
+
const visit = file => {
|
|
571
|
+
check(file); const st = fs.lstatSync(file);
|
|
572
|
+
if (st.isDirectory()) for (const name of fs.readdirSync(file)) visit(path.join(file, name));
|
|
573
|
+
else if (!st.isFile()) throw new Error('PRIVILEGED_HELPER_UNPROTECTED_PATH');
|
|
574
|
+
};
|
|
575
|
+
visit(app);
|
|
576
|
+
return import(pathToFileURL(path.join(app, 'privileged-helper-provision.mjs')).href);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Selected Linux identity maintenance. All OS mutation is behind complete
|
|
581
|
+
* target/identity/rollback preflight. The caller is explicitly privileged;
|
|
582
|
+
* verifyReconnection must independently confirm the same target in normal
|
|
583
|
+
* authenticated management, not merely accept a local agent status claim.
|
|
584
|
+
* home/io/run are injectable solely for the existing disposable fixtures.
|
|
585
|
+
*/
|
|
586
|
+
export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, authorizeMaintenance, verifyReconnection,
|
|
587
|
+
home = '/var/lib/nodecommand', systemUnit = '/etc/systemd/system/nc-client.service', io = fs, run = child_process.spawnSync, uid = process.geteuid?.() } = {}) {
|
|
588
|
+
if (uid !== 0 || os.platform() !== 'linux') throw new Error('IDENTITY_MAINTENANCE_ROOT_REQUIRED');
|
|
589
|
+
const fail = reason => { throw new Error(`IDENTITY_MAINTENANCE_REFUSED:${reason}`); };
|
|
590
|
+
if (!/^nc_[0-9a-f]{64}$/.test(hostId || '') || !ncRoot || path.resolve(ncRoot) !== ncRoot ||
|
|
591
|
+
!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');
|
|
592
|
+
const command = (exe, args, { optional = false } = {}) => {
|
|
593
|
+
const result = run(exe, args, { encoding: 'utf8', shell: false, timeout: 60000, maxBuffer: 65536,
|
|
594
|
+
env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
|
|
595
|
+
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`); }
|
|
596
|
+
return String(result.stdout || '');
|
|
597
|
+
};
|
|
598
|
+
const exists = file => { try { return io.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
599
|
+
const directoryFd = directory => {
|
|
600
|
+
let fd = io.openSync('/', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
|
601
|
+
try {
|
|
602
|
+
for (const component of path.resolve(directory).split('/').filter(Boolean)) {
|
|
603
|
+
const next = io.openSync(`/proc/self/fd/${fd}/${component}`, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
604
|
+
io.closeSync(fd); fd = next;
|
|
605
|
+
}
|
|
606
|
+
return fd;
|
|
607
|
+
} catch (error) { io.closeSync(fd); throw error; }
|
|
608
|
+
};
|
|
609
|
+
const read = file => {
|
|
610
|
+
const parent = directoryFd(path.dirname(file));
|
|
611
|
+
let fd;
|
|
612
|
+
try { fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); }
|
|
613
|
+
finally { io.closeSync(parent); }
|
|
614
|
+
try { const st = io.fstatSync(fd); if (!st.isFile() || st.size > 65536) fail('unsafe installation metadata'); return io.readFileSync(fd, 'utf8'); }
|
|
615
|
+
finally { io.closeSync(fd); }
|
|
616
|
+
};
|
|
617
|
+
const write = (file, text, mode) => {
|
|
618
|
+
const parent = directoryFd(path.dirname(file)); let fd;
|
|
619
|
+
try {
|
|
620
|
+
fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}`, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, mode);
|
|
621
|
+
const st = io.fstatSync(fd);
|
|
622
|
+
if (!st.isFile() || st.nlink !== 1) fail('maintenance destination is not a single regular file');
|
|
623
|
+
io.ftruncateSync(fd, 0); io.writeFileSync(fd, text); io.fsyncSync(fd);
|
|
624
|
+
} finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
|
|
625
|
+
};
|
|
626
|
+
const envPath = path.join(ncRoot, 'config/nc-client.env');
|
|
627
|
+
const envText = read(envPath), env = parseMaintenanceEnv(envText);
|
|
628
|
+
if (env.NC_CLIENT_ID !== hostId) fail('host ID mismatch; no token was consumed');
|
|
629
|
+
const identity = root => {
|
|
630
|
+
const data = parseMaintenanceEnv(read(path.join(root, 'config/nc-client.env')));
|
|
631
|
+
const key = crypto.createPrivateKey(read(path.join(root, 'config/nc-device-key.pem')));
|
|
632
|
+
return { client_id: data.NC_CLIENT_ID, server_url: data.NC_SERVER_URL,
|
|
633
|
+
device_key_id: crypto.createHash('sha256').update(crypto.createPublicKey(key).export({ type: 'spki', format: 'der' })).digest('hex') };
|
|
634
|
+
};
|
|
635
|
+
const protectedDirectory = directory => {
|
|
636
|
+
for (let current = directory; ; current = path.dirname(current)) {
|
|
637
|
+
const st = io.lstatSync(current);
|
|
638
|
+
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) fail('maintenance/rollback ancestry is not root-protected');
|
|
639
|
+
if (path.dirname(current) === current) break;
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
const rootInfo = io.lstatSync(ncRoot);
|
|
643
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail('source root is not a directory');
|
|
644
|
+
const loginMove = env.NC_SERVICE_MODE === 'user';
|
|
645
|
+
const oldUser = loginMove ? operatorUser : '_nc';
|
|
646
|
+
if (!/^[a-z_][a-z0-9_-]*[$]?$/.test(oldUser || '') || oldUser === 'root' || oldUser === 'nodecommand') fail('select the existing ordinary account explicitly');
|
|
647
|
+
const account = command('/usr/bin/getent', ['passwd', oldUser]).trim().split(':');
|
|
648
|
+
if (account.length !== 7 || account[0] !== oldUser || !/^\d+$/.test(account[2]) || !/^\d+$/.test(account[3])) fail('account inventory is ambiguous');
|
|
649
|
+
const oldUid = Number(account[2]), oldGid = Number(account[3]), oldHome = account[5];
|
|
650
|
+
if (oldUid === 0 || oldGid === 0 || (!loginMove && !/\/(?:nologin|false)$/.test(account[6]))) fail('selected account is not a dedicated non-root identity');
|
|
651
|
+
if (command('/usr/bin/getent', ['passwd', 'nodecommand'], { optional: true }) !== null ||
|
|
652
|
+
command('/usr/bin/getent', ['group', 'nodecommand'], { optional: true }) !== null) fail('nodecommand already exists; never merge an unrelated identity');
|
|
653
|
+
if (loginMove && (ncRoot === '/opt/nc' || !ncRoot.startsWith(oldHome + '/') || rootInfo.uid !== oldUid)) fail('the selected user tree does not belong to the selected login identity');
|
|
654
|
+
if (!loginMove && rootInfo.uid !== 0) fail('legacy dedicated system root is not protected');
|
|
655
|
+
const keyFdParent = directoryFd(path.join(ncRoot, 'config'));
|
|
656
|
+
let keyFd, selectedPrivateKey;
|
|
657
|
+
try {
|
|
658
|
+
keyFd = io.openSync(`/proc/self/fd/${keyFdParent}/nc-device-key.pem`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
659
|
+
const st = io.fstatSync(keyFd);
|
|
660
|
+
if (!st.isFile() || st.uid !== oldUid || st.size > 65536) fail('device key is not owned by the selected ordinary identity');
|
|
661
|
+
selectedPrivateKey = crypto.createPrivateKey(io.readFileSync(keyFd, 'utf8'));
|
|
662
|
+
} finally { if (keyFd !== undefined) io.closeSync(keyFd); io.closeSync(keyFdParent); }
|
|
663
|
+
const originalIdentity = { client_id: env.NC_CLIENT_ID, server_url: env.NC_SERVER_URL,
|
|
664
|
+
device_key_id: crypto.createHash('sha256').update(crypto.createPublicKey(selectedPrivateKey).export({ type: 'spki', format: 'der' })).digest('hex') };
|
|
665
|
+
if (!loginMove) protectedDirectory(ncRoot);
|
|
666
|
+
protectedDirectory(path.dirname(home));
|
|
667
|
+
const unit = loginMove ? path.join(oldHome, '.config/systemd/user/nc-client.service') : systemUnit;
|
|
668
|
+
if (!loginMove) {
|
|
669
|
+
protectedDirectory(path.dirname(unit));
|
|
670
|
+
const st = io.lstatSync(unit);
|
|
671
|
+
if (!st.isFile() || st.isSymbolicLink() || st.uid !== 0 || st.nlink !== 1 || (st.mode & 0o022)) fail('selected system unit is not root-protected');
|
|
672
|
+
}
|
|
673
|
+
const unitText = read(unit);
|
|
674
|
+
if (!unitText.split(/\r?\n/).includes(`WorkingDirectory=${ncRoot}/app`) ||
|
|
675
|
+
(!loginMove && (!unitText.split(/\r?\n/).includes('User=_nc') || !unitText.split(/\r?\n/).includes('Group=_nc')))) fail('selected service does not match this root/account');
|
|
676
|
+
if (loginMove && exists(systemUnit)) {
|
|
677
|
+
// A held global instance is inventory only. Never stop, adopt or rewrite it.
|
|
678
|
+
if (read(systemUnit).includes(`WorkingDirectory=${ncRoot}/app`)) fail('both managers claim this selected tree; repair the ambiguity first');
|
|
679
|
+
}
|
|
680
|
+
if (!loginMove) {
|
|
681
|
+
const aliases = command('/usr/bin/getent', ['passwd']).trim().split('\n').filter(line => Number(line.split(':')[2]) === oldUid);
|
|
682
|
+
if (aliases.length !== 1 || aliases[0].split(':')[0] !== '_nc') fail('legacy UID is shared; explicit identity reconciliation is required');
|
|
683
|
+
if (/^\s*(?:AmbientCapabilities|SupplementaryGroups|CapabilityBoundingSet)\s*=\s*\S/m.test(unitText)) fail('legacy service has additional privilege channels; repair explicitly first');
|
|
684
|
+
const group = command('/usr/bin/getent', ['group', '_nc']).trim().split(':');
|
|
685
|
+
if (Number(group[2]) !== oldGid) fail('legacy primary group mismatch');
|
|
686
|
+
const groupAliases = command('/usr/bin/getent', ['group']).trim().split('\n').filter(line => Number(line.split(':')[2]) === oldGid);
|
|
687
|
+
if (groupAliases.length !== 1 || groupAliases[0].split(':')[0] !== '_nc') fail('legacy GID is shared; no identity rename performed');
|
|
688
|
+
const journal = command('/usr/bin/getent', ['group', 'systemd-journal'], { optional: true });
|
|
689
|
+
const allowed = [oldGid, ...(journal ? [Number(journal.trim().split(':')[2])] : [])];
|
|
690
|
+
if (command('/usr/bin/id', ['-G', '_nc']).trim().split(/\s+/).some(gid => !allowed.includes(Number(gid)))) fail('legacy account has unapproved supplementary authority');
|
|
691
|
+
}
|
|
692
|
+
const helperEdits = [];
|
|
693
|
+
if (!loginMove && exists('/etc/systemd/system/nc-privileged-helper.socket')) {
|
|
694
|
+
const helperService = read('/etc/systemd/system/nc-privileged-helper.service');
|
|
695
|
+
if (!helperService.includes(`${ncRoot}/libexec/privileged-helper/privileged-helper-linux-daemon.mjs`)) fail('helper resources belong to another or unprotected installation');
|
|
696
|
+
const helperConfigPath = path.join(ncRoot, 'libexec/privileged-helper/privileged-helper.json');
|
|
697
|
+
protectedDirectory(path.dirname(helperConfigPath));
|
|
698
|
+
const helperConfig = JSON.parse(read(helperConfigPath));
|
|
699
|
+
if (helperConfig.client_id !== hostId || helperConfig.service_uid !== oldUid || helperConfig.service_gid !== oldGid) fail('helper peer binding is not this selected identity');
|
|
700
|
+
for (const file of ['/etc/systemd/system/nc-privileged-helper.socket', '/etc/tmpfiles.d/nc-privileged-helper.conf']) {
|
|
701
|
+
if (exists(file)) {
|
|
702
|
+
protectedDirectory(path.dirname(file));
|
|
703
|
+
const text = read(file);
|
|
704
|
+
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') });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const destination = loginMove ? path.join(home, 'nc') : ncRoot;
|
|
709
|
+
if (exists(home)) fail('restricted home/destination already occupied');
|
|
710
|
+
if (exists(unit + '.identity-backup')) fail('an old service recovery copy is already present');
|
|
711
|
+
if (loginMove && exists(envPath + '.identity-backup')) fail('an old configuration recovery copy is already present');
|
|
712
|
+
const runtimeMatch = unitText.match(/^ExecStart=(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))\s*$/m);
|
|
713
|
+
const oldNode = runtimeMatch?.[1] || runtimeMatch?.[2];
|
|
714
|
+
if (!oldNode || (runtimeMatch[3] || runtimeMatch[4]) !== `${ncRoot}/app/launcher.mjs` || !path.isAbsolute(oldNode)) fail('runtime launch cannot be preserved safely');
|
|
715
|
+
const node = loginMove && oldNode.startsWith(ncRoot + '/') ? destination + oldNode.slice(ncRoot.length) : oldNode;
|
|
716
|
+
if (loginMove && oldNode.startsWith(oldHome + '/') && !oldNode.startsWith(ncRoot + '/')) fail('runtime belongs to the former login home; select an isolated runtime before migration');
|
|
717
|
+
const sudo = run('/usr/bin/sudo', ['-n', '-l', '-U', oldUser], { encoding: 'utf8', shell: false, timeout: 10000,
|
|
718
|
+
env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
|
|
719
|
+
if (!loginMove && !sudo.error && (sudo.status === 0 || sudo.status !== 1)) fail('legacy account has sudo authority or its sudo rules cannot be verified');
|
|
720
|
+
if (!loginMove && sudo.error && sudo.error.code !== 'ENOENT') fail('legacy sudo authority is unknown');
|
|
721
|
+
const manager = (user, userId, userHome, args) => command('/usr/sbin/runuser', ['-u', user, '--', '/usr/bin/env', '-i',
|
|
722
|
+
'PATH=/usr/sbin:/usr/bin:/sbin:/bin', `HOME=${userHome}`, `XDG_RUNTIME_DIR=/run/user/${userId}`,
|
|
723
|
+
`DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${userId}/bus`, '/usr/bin/systemctl', '--user', ...args]);
|
|
724
|
+
const oldManager = args => loginMove ? manager(oldUser, oldUid, oldHome, args) : command('/usr/bin/systemctl', args);
|
|
725
|
+
oldManager(['is-active', 'nc-client.service']);
|
|
726
|
+
// Check all rollback tools and manager reachability BEFORE stopping anything.
|
|
727
|
+
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']);
|
|
728
|
+
if (loginMove) {
|
|
729
|
+
const before = command('/usr/bin/loginctl', ['show-user', oldUser, '--property=Linger', '--value']).trim();
|
|
730
|
+
if (!['yes', 'no'].includes(before)) fail('old linger inventory is unknown');
|
|
731
|
+
} else if (exists(`/var/lib/systemd/linger/${oldUser}`)) fail('legacy user-manager/linger requires selected reconciliation before rename');
|
|
732
|
+
if (await authorizeMaintenance({ ...originalIdentity, ncRoot, old_user: oldUser, uid: oldUid, gid: oldGid,
|
|
733
|
+
privateKey: selectedPrivateKey, hostToken: env.NC_CLIENT_TOKEN }) !== true) fail('selected target maintenance was not authorized; held installations remain untouched');
|
|
734
|
+
const recovery = loginMove ? home : path.join(ncRoot, 'libexec');
|
|
735
|
+
if (!loginMove) {
|
|
736
|
+
io.mkdirSync(recovery, { recursive: true, mode: 0o755 });
|
|
737
|
+
const st = io.lstatSync(recovery); if (st.uid !== 0 || st.isSymbolicLink() || (st.mode & 0o022)) fail('rollback directory is not protected');
|
|
738
|
+
} else io.mkdirSync(home, { mode: 0o750 });
|
|
739
|
+
const journalPath = path.join(recovery, '.identity-maintenance.json');
|
|
740
|
+
if (exists(journalPath)) fail('an earlier identity maintenance needs recovery first');
|
|
741
|
+
const recoveryRecord = { host_id: hostId, source: ncRoot, destination, old_user: oldUser,
|
|
742
|
+
old_home: oldHome, uid: oldUid, gid: oldGid, identity: originalIdentity, unit, original_unit: unitText, original_env: envText, helper_edits: helperEdits };
|
|
743
|
+
const recordRecovery = (record, initial = false) => {
|
|
744
|
+
const target = initial ? journalPath : journalPath + '.next';
|
|
745
|
+
const fd = io.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
746
|
+
try { io.writeFileSync(fd, JSON.stringify(record) + '\n'); io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
747
|
+
if (!initial) io.renameSync(target, journalPath);
|
|
748
|
+
const directory = directoryFd(recovery);
|
|
749
|
+
try { io.fsyncSync(directory); } finally { io.closeSync(directory); }
|
|
750
|
+
};
|
|
751
|
+
recordRecovery({ ...recoveryRecord, state: 'prepared' }, true);
|
|
752
|
+
let stopped = false, accountCreated = false, groupCreated = false, renamedUser = false, renamedGroup = false, newStarted = false, managerStarted = false, retiredUnit = false, retiredEnv = false, changedHome = false;
|
|
753
|
+
const newUnit = loginMove ? path.join(home, '.config/systemd/user/nc-client.service') : unit;
|
|
754
|
+
let newUid = oldUid, newGid = oldGid;
|
|
755
|
+
try {
|
|
756
|
+
stopped = true; oldManager(['stop', 'nc-client.service']);
|
|
757
|
+
if (JSON.stringify(identity(ncRoot)) !== JSON.stringify(originalIdentity)) fail('source identity changed during preflight');
|
|
758
|
+
if (loginMove) {
|
|
759
|
+
// Open every source component with O_NOFOLLOW; root never follows a
|
|
760
|
+
// login-user replacement/symlink into another home or credential file.
|
|
761
|
+
const copy = (sourceFd, target, relative = '') => {
|
|
762
|
+
io.mkdirSync(target, { mode: 0o700 });
|
|
763
|
+
for (const name of io.readdirSync(`/proc/self/fd/${sourceFd}`)) {
|
|
764
|
+
if (!relative && !['app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'install.sh', 'uninstall.sh'].includes(name)) continue;
|
|
765
|
+
const source = `/proc/self/fd/${sourceFd}/${name}`, dest = path.join(target, name);
|
|
766
|
+
const st = io.lstatSync(source);
|
|
767
|
+
if (st.isSymbolicLink()) {
|
|
768
|
+
const link = io.readlinkSync(source);
|
|
769
|
+
if (path.isAbsolute(link) || !path.resolve(ncRoot, relative, link).startsWith(ncRoot + '/')) fail('external source symlink; explicitly export the selected dependency first');
|
|
770
|
+
io.symlinkSync(link, dest); continue;
|
|
771
|
+
}
|
|
772
|
+
const fd = io.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
773
|
+
try {
|
|
774
|
+
const before = io.fstatSync(fd);
|
|
775
|
+
if (before.uid !== oldUid && !(before.uid === 0 && (before.mode & 0o004))) fail('unrelated private source ownership');
|
|
776
|
+
if (before.isDirectory()) copy(fd, dest, path.join(relative, name));
|
|
777
|
+
else if (before.isFile()) {
|
|
778
|
+
const output = io.openSync(dest, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, before.mode & 0o777);
|
|
779
|
+
try {
|
|
780
|
+
const buffer = Buffer.alloc(65536); let count;
|
|
781
|
+
while ((count = io.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
|
|
782
|
+
let offset = 0; while (offset < count) offset += io.writeSync(output, buffer, offset, count - offset);
|
|
783
|
+
}
|
|
784
|
+
} finally { io.closeSync(output); }
|
|
785
|
+
const after = io.fstatSync(fd);
|
|
786
|
+
if (before.size !== after.size || before.ctimeMs !== after.ctimeMs) fail('source changed while copying');
|
|
787
|
+
} else fail('special files cannot be relocated');
|
|
788
|
+
} finally { io.closeSync(fd); }
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
const sourceFd = directoryFd(ncRoot);
|
|
792
|
+
const pinned = io.fstatSync(sourceFd);
|
|
793
|
+
if (pinned.ino !== rootInfo.ino || pinned.dev !== rootInfo.dev) { io.closeSync(sourceFd); fail('selected source root was replaced'); }
|
|
794
|
+
try { copy(sourceFd, destination); } finally { io.closeSync(sourceFd); }
|
|
795
|
+
if (JSON.stringify(identity(destination)) !== JSON.stringify(originalIdentity)) fail('relocation identity mismatch');
|
|
796
|
+
command('/usr/sbin/groupadd', ['--system', 'nodecommand']); groupCreated = true;
|
|
797
|
+
command('/usr/sbin/useradd', ['--system', '--gid', 'nodecommand', '--home-dir', home, '--shell', '/usr/sbin/nologin', 'nodecommand']); accountCreated = true;
|
|
798
|
+
const created = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
|
|
799
|
+
newUid = Number(created[2]); newGid = Number(created[3]);
|
|
800
|
+
if (!Number.isSafeInteger(newUid) || newUid <= 0 || !Number.isSafeInteger(newGid) || newGid <= 0 || created[5] !== home) fail('new account inventory mismatch');
|
|
801
|
+
const rewritten = { ...parseMaintenanceEnv(read(path.join(destination, 'config/nc-client.env'))), NC_SERVICE_MODE: 'user',
|
|
802
|
+
NC_ROOT: destination, NC_SERVICE_UNIT: 'nc-client.service', NC_OPERATOR_USER: 'nodecommand', NC_SERVICE_USER: 'nodecommand', NC_SERVICE_GROUP: 'nodecommand' };
|
|
803
|
+
for (const name of ['NC_ENV_FILE', 'NC_LOG_FILE', 'NC_DEVICE_KEY_FILE', 'NC_NODE_BIN']) {
|
|
804
|
+
if (rewritten[name]?.startsWith(ncRoot + '/')) rewritten[name] = destination + rewritten[name].slice(ncRoot.length);
|
|
805
|
+
}
|
|
806
|
+
// Keep server, host, token, device key, private runtime and policy roots.
|
|
807
|
+
write(path.join(destination, 'config/nc-client.env'), Object.entries(rewritten).map(([key, value]) => `${key}=${value}`).join('\n') + '\n', 0o600);
|
|
808
|
+
const own = dir => { for (const entry of io.readdirSync(dir)) { const file = path.join(dir, entry); const st = io.lstatSync(file);
|
|
809
|
+
if (st.isSymbolicLink()) { const actual = io.realpathSync(file); if (!actual.startsWith(destination + '/')) fail('relocated symlink escapes installation'); io.lchownSync(file, newUid, newGid); }
|
|
810
|
+
else { if (st.isDirectory()) own(file); io.chownSync(file, newUid, newGid); } } };
|
|
811
|
+
own(destination); io.chownSync(destination, newUid, newGid);
|
|
812
|
+
io.chownSync(home, 0, newGid);
|
|
813
|
+
io.mkdirSync(path.join(home, 'workspace'), { mode: 0o700 }); io.chownSync(path.join(home, 'workspace'), newUid, newGid);
|
|
814
|
+
for (const relative of ['.config', '.config/systemd', '.config/systemd/user', '.cache', '.local', '.local/share']) {
|
|
815
|
+
const directory = path.join(home, relative); io.mkdirSync(directory, { mode: 0o700 }); io.chownSync(directory, newUid, newGid);
|
|
816
|
+
}
|
|
817
|
+
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 });
|
|
818
|
+
io.chownSync(newUnit, newUid, newGid);
|
|
819
|
+
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']) {
|
|
820
|
+
if (!exists(socket)) continue;
|
|
821
|
+
const access = run('/usr/sbin/runuser', ['-u', 'nodecommand', '--', '/usr/bin/test', '-w', socket], {
|
|
822
|
+
encoding: 'utf8', shell: false, timeout: 10000, env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin' } });
|
|
823
|
+
if (access.error || access.status !== 1) fail('restricted identity can access a root-equivalent IPC endpoint or its access cannot be verified');
|
|
824
|
+
}
|
|
825
|
+
command('/usr/bin/loginctl', ['enable-linger', 'nodecommand']);
|
|
826
|
+
managerStarted = true;
|
|
827
|
+
command('/usr/bin/systemctl', ['start', `user@${newUid}.service`]);
|
|
828
|
+
} else {
|
|
829
|
+
const processes = run('/usr/bin/ps', ['-u', String(oldUid), '-o', 'pid='], { encoding: 'utf8', shell: false, timeout: 10000,
|
|
830
|
+
env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
|
|
831
|
+
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');
|
|
832
|
+
command('/usr/sbin/usermod', ['--login', 'nodecommand', '_nc']); renamedUser = true;
|
|
833
|
+
command('/usr/sbin/groupmod', ['--new-name', 'nodecommand', '_nc']); renamedGroup = true;
|
|
834
|
+
const renamed = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
|
|
835
|
+
if (renamed[0] !== 'nodecommand' || Number(renamed[2]) !== oldUid || Number(renamed[3]) !== oldGid ||
|
|
836
|
+
Number(command('/usr/bin/getent', ['group', 'nodecommand']).trim().split(':')[2]) !== oldGid) fail('account rename did not preserve UID/GID');
|
|
837
|
+
io.mkdirSync(home, { mode: 0o750 }); changedHome = true; io.chownSync(home, 0, oldGid);
|
|
838
|
+
// Change only passwd home metadata; never move an arbitrary former
|
|
839
|
+
// account home or recursively change its files/permissions.
|
|
840
|
+
command('/usr/sbin/usermod', ['--home', home, 'nodecommand']);
|
|
841
|
+
const relocated = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
|
|
842
|
+
if (relocated[5] !== home || Number(relocated[2]) !== oldUid || Number(relocated[3]) !== oldGid) fail('new home metadata or stable UID/GID could not be verified');
|
|
843
|
+
for (const relative of ['workspace', '.config', '.config/systemd', '.config/systemd/user', '.cache', '.local', '.local/share']) {
|
|
844
|
+
const directory = path.join(home, relative); io.mkdirSync(directory, { mode: 0o700 }); io.chownSync(directory, oldUid, oldGid);
|
|
845
|
+
}
|
|
846
|
+
// Regenerate the ordinary service rather than retaining root-capable
|
|
847
|
+
// ExecStartPre/credential hooks or an old login environment.
|
|
848
|
+
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);
|
|
849
|
+
write(envPath, envText.replace(/^(NC_SERVICE_USER|NC_SERVICE_GROUP|NC_OPERATOR_USER)=(?:"_nc"|'_nc'|_nc)$/gm, '$1=nodecommand'), 0o600);
|
|
850
|
+
for (const edit of helperEdits) write(edit.file, edit.replacement, 0o644);
|
|
851
|
+
command('/usr/bin/loginctl', ['enable-linger', 'nodecommand']);
|
|
852
|
+
managerStarted = true;
|
|
853
|
+
command('/usr/bin/systemctl', ['start', `user@${newUid}.service`]);
|
|
854
|
+
}
|
|
855
|
+
const newManager = args => loginMove ? manager('nodecommand', newUid, home, args) : command('/usr/bin/systemctl', args);
|
|
856
|
+
newManager(['daemon-reload']); newStarted = true; newManager(['enable', '--now', 'nc-client.service']);
|
|
857
|
+
newManager(['is-active', 'nc-client.service']);
|
|
858
|
+
const currentIdentity = identity(destination);
|
|
859
|
+
if (JSON.stringify(currentIdentity) !== JSON.stringify(originalIdentity) || await verifyReconnection({ ...currentIdentity, ncRoot: destination, service_user: 'nodecommand', uid: newUid, gid: newGid,
|
|
860
|
+
privateKey: selectedPrivateKey, hostToken: env.NC_CLIENT_TOKEN }) !== true) fail('same-host reconnection not independently confirmed');
|
|
861
|
+
if (loginMove) {
|
|
862
|
+
oldManager(['disable', 'nc-client.service']);
|
|
863
|
+
const retire = (file, text) => {
|
|
864
|
+
const parent = directoryFd(path.dirname(file)); let fd;
|
|
865
|
+
try {
|
|
866
|
+
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);
|
|
867
|
+
io.writeFileSync(fd, text); io.fsyncSync(fd);
|
|
868
|
+
io.unlinkSync(`/proc/self/fd/${parent}/${path.basename(file)}`);
|
|
869
|
+
} finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
|
|
870
|
+
};
|
|
871
|
+
retire(unit, unitText); retiredUnit = true;
|
|
872
|
+
retire(envPath, envText); retiredEnv = true;
|
|
873
|
+
}
|
|
874
|
+
recordRecovery({ ...recoveryRecord, state: 'verified', new_uid: newUid, new_gid: newGid });
|
|
875
|
+
return { ...currentIdentity, ncRoot: destination, service_user: 'nodecommand', uid: newUid, gid: newGid,
|
|
876
|
+
workspace: path.join(home, 'workspace'), helper_readiness: 'unavailable', existing_permission: 'preserved', rollback_journal: journalPath };
|
|
877
|
+
} catch (error) {
|
|
878
|
+
try {
|
|
879
|
+
if (newStarted) {
|
|
880
|
+
if (loginMove) manager('nodecommand', newUid, home, ['disable', '--now', 'nc-client.service']);
|
|
881
|
+
else command('/usr/bin/systemctl', ['stop', 'nc-client.service']);
|
|
882
|
+
}
|
|
883
|
+
if (loginMove) {
|
|
884
|
+
if (accountCreated) { command('/usr/bin/loginctl', ['disable-linger', 'nodecommand']); command('/usr/bin/systemctl', ['stop', `user@${newUid}.service`]); command('/usr/sbin/userdel', ['nodecommand']); }
|
|
885
|
+
if (groupCreated) command('/usr/sbin/groupdel', ['nodecommand']);
|
|
886
|
+
io.rmSync(home, { recursive: true });
|
|
887
|
+
if (retiredUnit) { write(unit, unitText, 0o600); io.chownSync(unit, oldUid, oldGid); io.unlinkSync(unit + '.identity-backup'); }
|
|
888
|
+
if (retiredEnv) { write(envPath, envText, 0o600); io.chownSync(envPath, oldUid, oldGid); io.unlinkSync(envPath + '.identity-backup'); }
|
|
889
|
+
} else {
|
|
890
|
+
if (managerStarted) { command('/usr/bin/loginctl', ['disable-linger', 'nodecommand']); command('/usr/bin/systemctl', ['stop', `user@${oldUid}.service`]); }
|
|
891
|
+
if (changedHome) { command('/usr/sbin/usermod', ['--home', oldHome, 'nodecommand']); io.rmSync(home, { recursive: true }); }
|
|
892
|
+
if (renamedGroup) command('/usr/sbin/groupmod', ['--new-name', '_nc', 'nodecommand']);
|
|
893
|
+
if (renamedUser) command('/usr/sbin/usermod', ['--login', '_nc', 'nodecommand']);
|
|
894
|
+
write(unit, unitText, 0o644); write(envPath, envText, 0o600);
|
|
895
|
+
for (const edit of helperEdits) write(edit.file, edit.text, 0o644);
|
|
896
|
+
command('/usr/bin/systemctl', ['daemon-reload']);
|
|
897
|
+
}
|
|
898
|
+
if (stopped) oldManager(['start', 'nc-client.service']);
|
|
899
|
+
if (!loginMove) io.rmSync(journalPath);
|
|
900
|
+
} catch { throw new Error('IDENTITY_MAINTENANCE_ROLLBACK_REQUIRED: preserve the protected recovery journal; do not rerun installation or replace credentials', { cause: error }); }
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
245
905
|
/**
|
|
246
906
|
* Uninstaller script living inside an installed tree. The scripts are
|
|
247
907
|
* self-locating ($ROOT derived from their own path), so no root argument is
|
|
@@ -255,8 +915,8 @@ export function uninstallerForRoot(root, platform = os.platform()) {
|
|
|
255
915
|
}
|
|
256
916
|
|
|
257
917
|
/**
|
|
258
|
-
* Suggested-action line printed when an uninstaller fails. The
|
|
259
|
-
*
|
|
918
|
+
* Suggested-action line printed when an uninstaller fails. The Unix text
|
|
919
|
+
* requires trusted maintenance, never implicit sudo; win32 keeps the generic wording because
|
|
260
920
|
* the .ps1 uninstaller already runs with the caller's own rights (no sudo
|
|
261
921
|
* equivalent is attempted there).
|
|
262
922
|
* @param {string} [platform]
|
|
@@ -264,31 +924,28 @@ export function uninstallerForRoot(root, platform = os.platform()) {
|
|
|
264
924
|
export function uninstallFailureSuggestion(platform = os.platform()) {
|
|
265
925
|
const base = 'Re-run with administrator/root rights, then retry.';
|
|
266
926
|
if (platform === 'win32') return `suggested_action: ${base}`;
|
|
267
|
-
return
|
|
927
|
+
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.';
|
|
268
928
|
}
|
|
269
929
|
|
|
930
|
+
/** User-mode authority comes from the selected tree, not the caller's UID. */
|
|
931
|
+
export function isUserInstallRoot(root) {
|
|
932
|
+
try {
|
|
933
|
+
return /^NC_SERVICE_MODE=user\r?$/m.test(fs.readFileSync(path.join(root, 'config', 'nc-client.env'), 'utf8').replace(/^\uFEFF/, ''));
|
|
934
|
+
} catch {
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
export const LINUX_PAIRED_UNINSTALL_ENTRY = '/opt/nc/libexec/privileged-helper/install/uninstall-linux.sh';
|
|
940
|
+
|
|
270
941
|
/**
|
|
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.
|
|
942
|
+
* Discovery markers are not execution trust. POSIX removal never elevates:
|
|
943
|
+
* an ordinary caller may execute only its own user tree; root may execute
|
|
944
|
+
* only a protected system script through a protected absolute interpreter.
|
|
945
|
+
* Never forward startup hooks or loader/environment authority to that shell.
|
|
289
946
|
* @param {string} scriptPath
|
|
290
947
|
* @param {string|{ platform?: string, execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [platform]
|
|
291
|
-
|
|
948
|
+
* @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs }} [options]
|
|
292
949
|
*/
|
|
293
950
|
export function runUninstallerScript(scriptPath, platform = os.platform(), options = {}) {
|
|
294
951
|
let opts = options || {};
|
|
@@ -309,7 +966,7 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
|
|
|
309
966
|
typeof opts.getuid === 'function'
|
|
310
967
|
? opts.getuid
|
|
311
968
|
: typeof process.getuid === 'function'
|
|
312
|
-
|
|
969
|
+
? () => process.geteuid?.() ?? process.getuid()
|
|
313
970
|
: null;
|
|
314
971
|
let uid = null;
|
|
315
972
|
try {
|
|
@@ -317,17 +974,48 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
|
|
|
317
974
|
} catch {
|
|
318
975
|
uid = null;
|
|
319
976
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
977
|
+
const io = opts.io || fs;
|
|
978
|
+
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' });
|
|
979
|
+
const pairedEntry = platform === 'linux' && scriptPath === LINUX_PAIRED_UNINSTALL_ENTRY;
|
|
980
|
+
if (!Number.isSafeInteger(uid) || uid < 0 || !path.isAbsolute(scriptPath) || path.resolve(scriptPath) !== scriptPath || (!pairedEntry && path.basename(scriptPath) !== 'uninstall.sh')) throw denied();
|
|
981
|
+
const root = path.dirname(scriptPath);
|
|
982
|
+
let userMode = false;
|
|
983
|
+
if (!pairedEntry) try { userMode = /^NC_SERVICE_MODE=user\r?$/m.test(io.readFileSync(path.join(root, 'config/nc-client.env'), 'utf8').replace(/^\uFEFF/, '')); } catch {}
|
|
984
|
+
const env = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'C', LC_ALL: 'C' };
|
|
985
|
+
const protectedPath = (file, executable = false, checkAcl = true) => {
|
|
986
|
+
if (io.realpathSync(file) !== file) throw denied();
|
|
987
|
+
for (let current = file; ; current = path.dirname(current)) {
|
|
988
|
+
const st = io.lstatSync(current);
|
|
989
|
+
// Linux POSIX ACL write grants are bounded by the group-class mask.
|
|
990
|
+
if (st.isSymbolicLink() || st.uid !== 0 || (pairedEntry && st.gid !== 0) || (st.mode & 0o022) ||
|
|
991
|
+
(current === file ? !st.isFile() || st.nlink !== 1 || (executable && !(st.mode & 0o111)) : !st.isDirectory())) throw denied();
|
|
992
|
+
if (platform === 'darwin' && checkAcl) {
|
|
993
|
+
const ls = io.realpathSync('/bin/ls');
|
|
994
|
+
protectedPath(ls, true, false);
|
|
995
|
+
const acl = execFile(ls, ['-lde', current], { env, encoding: 'utf8' });
|
|
996
|
+
if (typeof acl !== 'string' || !acl.trim() || acl.trim().split(/\r?\n/).length !== 1) throw denied();
|
|
997
|
+
}
|
|
998
|
+
if (current === path.dirname(current)) break;
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
324
1001
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
1002
|
+
if (pairedEntry && ((opts.getuid || process.getuid)?.() !== 0 || (opts.geteuid || process.geteuid)?.() !== 0)) throw denied();
|
|
1003
|
+
if (uid === 0) {
|
|
1004
|
+
if (userMode) throw denied();
|
|
1005
|
+
protectedPath(scriptPath);
|
|
1006
|
+
} else {
|
|
1007
|
+
if (!userMode || io.realpathSync(root) !== root) throw denied();
|
|
1008
|
+
for (const [file, directory] of [[root, true], [scriptPath, false]]) {
|
|
1009
|
+
const st = io.lstatSync(file);
|
|
1010
|
+
if (st.isSymbolicLink() || st.uid !== uid || (directory ? !st.isDirectory() : !st.isFile() || st.nlink !== 1)) throw denied();
|
|
1011
|
+
}
|
|
1012
|
+
env.HOME = os.userInfo().homedir;
|
|
1013
|
+
}
|
|
1014
|
+
const bash = io.realpathSync('/bin/bash');
|
|
1015
|
+
protectedPath(bash, true);
|
|
1016
|
+
execFile(bash, ['--noprofile', '--norc', '-p', '--', scriptPath], { stdio: 'inherit', env, cwd: '/' });
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (error?.status !== undefined || error?.code === 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw error;
|
|
1019
|
+
throw denied();
|
|
332
1020
|
}
|
|
333
1021
|
}
|