node-command 2.0.11 → 2.0.13
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 +17 -7
- package/lib/setup.mjs +396 -17
- package/package.json +1 -1
package/bin/nc.mjs
CHANGED
|
@@ -24,7 +24,9 @@ import {
|
|
|
24
24
|
isPartialSystemRemovalCandidate,
|
|
25
25
|
isPartialUserRemovalCandidate,
|
|
26
26
|
isUserInstallRoot,
|
|
27
|
+
incompleteSystemInstallationShape,
|
|
27
28
|
probeInstallRoot,
|
|
29
|
+
removeIncompleteSystemInstallation,
|
|
28
30
|
removePartialSystemRoot,
|
|
29
31
|
removePartialUserRoot,
|
|
30
32
|
resolveExecutable,
|
|
@@ -3118,20 +3120,25 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
3118
3120
|
}
|
|
3119
3121
|
for (const root of removable) {
|
|
3120
3122
|
const pair = paired.find(selected => selected.ncRoot === root);
|
|
3123
|
+
// A pending-helper or failed-install system tree is removed by the narrow
|
|
3124
|
+
// bounded path below even when its packaged entry still exists: the
|
|
3125
|
+
// protected drain requires lifecycle custody those shapes never created.
|
|
3126
|
+
const incompleteShape = !pair && os.platform() === 'linux' ? incompleteSystemInstallationShape(root) : null;
|
|
3121
3127
|
const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
|
|
3122
3128
|
// A failed per-user install can leave its own tree (config plus partial
|
|
3123
3129
|
// payload) without the packaged uninstaller. Only the canonical user root
|
|
3124
3130
|
// is eligible, and the bounded removal revalidates ancestry, ownership and
|
|
3125
3131
|
// every top-level entry. Real system installs never use this fallback;
|
|
3126
3132
|
// the exact /opt/nc partial tree has its own narrower root-only exception.
|
|
3127
|
-
const scriptExists = pair || fs.existsSync(script);
|
|
3128
3133
|
const explicitRemoval = command === 'uninstall' || command === 'remove';
|
|
3134
|
+
const incompleteSystemTree = incompleteShape !== null && explicitRemoval && isPartialSystemRemovalCandidate(root);
|
|
3135
|
+
const scriptExists = pair || incompleteSystemTree || fs.existsSync(script);
|
|
3129
3136
|
const partialUserTree = !scriptExists && explicitRemoval && isPartialUserRemovalCandidate(root);
|
|
3130
3137
|
// The narrow system exception is /opt/nc only, requires real+effective
|
|
3131
3138
|
// root, and refuses app/, uninstall.sh, units, libexec/ and lifecycle
|
|
3132
3139
|
// metadata inside removePartialSystemRoot.
|
|
3133
3140
|
const partialSystemTree = !scriptExists && explicitRemoval && isPartialSystemRemovalCandidate(root);
|
|
3134
|
-
const boundedTree = partialUserTree || partialSystemTree;
|
|
3141
|
+
const boundedTree = partialUserTree || partialSystemTree || incompleteSystemTree;
|
|
3135
3142
|
if (!scriptExists && !boundedTree) {
|
|
3136
3143
|
console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
|
|
3137
3144
|
console.error(uninstallFailureSuggestion());
|
|
@@ -3149,15 +3156,18 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
3149
3156
|
try {
|
|
3150
3157
|
if (boundedTree) {
|
|
3151
3158
|
try {
|
|
3152
|
-
if (
|
|
3159
|
+
if (incompleteSystemTree) removeIncompleteSystemInstallation(root);
|
|
3160
|
+
else if (partialSystemTree) removePartialSystemRoot(root);
|
|
3153
3161
|
else removePartialUserRoot(root);
|
|
3154
|
-
console.log(
|
|
3155
|
-
? `Removed
|
|
3156
|
-
:
|
|
3162
|
+
console.log(incompleteSystemTree
|
|
3163
|
+
? `Removed ${incompleteShape.kind === 'pending-helper' ? 'pending-helper' : 'failed'} system Node Command installation at ${root} (exact unit/bounded tree only).`
|
|
3164
|
+
: partialSystemTree
|
|
3165
|
+
? `Removed partial system Node Command installation at ${root} (no packaged uninstaller, unit or application payload).`
|
|
3166
|
+
: `Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
|
|
3157
3167
|
} catch (err) {
|
|
3158
3168
|
const code = typeof err?.code === 'string' && /_REFUSED$/.test(err.code) ? err.code : 'PARTIAL_ROOT_REMOVAL_REFUSED';
|
|
3159
3169
|
console.error(`error: [${code}] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
|
|
3160
|
-
console.error(partialSystemTree
|
|
3170
|
+
console.error(incompleteSystemTree || partialSystemTree
|
|
3161
3171
|
? 'suggested_action: The system tree was left untouched. Inspect /opt/nc and complete or repair the failed system installation through explicit root maintenance; credentials/config were preserved.'
|
|
3162
3172
|
: 'suggested_action: The tree was left untouched. Repair it with `nodecommand --user`, or remove it yourself after confirming it is your own Node Command directory.');
|
|
3163
3173
|
failed += 1;
|
package/lib/setup.mjs
CHANGED
|
@@ -479,6 +479,42 @@ const PARTIAL_SYSTEM_FORBIDDEN_PATHS = [
|
|
|
479
479
|
'/var/lib/nodecommand/nc'
|
|
480
480
|
];
|
|
481
481
|
|
|
482
|
+
/** Files a rejected/expired enrollment may leave in the system config tree. */
|
|
483
|
+
const PARTIAL_ENROLLMENT_CONFIG_ENTRIES = new Set(['.nc-cli.lock', 'nc-device-key.pem', 'nc-enroll-state.json']);
|
|
484
|
+
|
|
485
|
+
/** The exact residue that identifies the narrow recoverable enrollment shape. */
|
|
486
|
+
const PARTIAL_ENROLLMENT_REQUIRED = ['nc-device-key.pem', 'nc-enroll-state.json'];
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* True only for the exact canonical system root carrying the residue of a
|
|
490
|
+
* rejected/expired enrollment: a root-owned config directory with the device
|
|
491
|
+
* key and enrollment state, no host environment, and no application payload.
|
|
492
|
+
* Discovery uses this so the abandoned tree is a removal target; removal
|
|
493
|
+
* revalidates every entry before deleting anything.
|
|
494
|
+
* @param {string} root
|
|
495
|
+
* @param {typeof fs} [io]
|
|
496
|
+
*/
|
|
497
|
+
function partialEnrollmentSystemRoot(root, io = fs) {
|
|
498
|
+
if (path.resolve(root) !== PARTIAL_SYSTEM_ROOT) return false;
|
|
499
|
+
const lstat = io.lstatSync || fs.lstatSync;
|
|
500
|
+
const readdir = io.readdirSync || fs.readdirSync;
|
|
501
|
+
try {
|
|
502
|
+
if (!readdir(PARTIAL_SYSTEM_ROOT).every((name) => PARTIAL_SYSTEM_ENTRIES.has(name))) return false;
|
|
503
|
+
const config = path.join(PARTIAL_SYSTEM_ROOT, 'config');
|
|
504
|
+
const configInfo = lstat(config);
|
|
505
|
+
if (configInfo.isSymbolicLink() || !configInfo.isDirectory()) return false;
|
|
506
|
+
const names = readdir(config);
|
|
507
|
+
if (!names.every((name) => PARTIAL_ENROLLMENT_CONFIG_ENTRIES.has(name))) return false;
|
|
508
|
+
if (!PARTIAL_ENROLLMENT_REQUIRED.every((name) => names.includes(name))) return false;
|
|
509
|
+
return PARTIAL_ENROLLMENT_REQUIRED.every((name) => {
|
|
510
|
+
const entry = lstat(path.join(config, name));
|
|
511
|
+
return !entry.isSymbolicLink() && entry.isFile();
|
|
512
|
+
});
|
|
513
|
+
} catch {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
482
518
|
/**
|
|
483
519
|
* True when `root` is the canonical per-user tree for the current account and
|
|
484
520
|
* is not a protected system root. This is the cheap path/ownership gate; the
|
|
@@ -522,8 +558,43 @@ export function isPartialSystemRemovalCandidate(root, { platform = os.platform()
|
|
|
522
558
|
return path.resolve(root) === PARTIAL_SYSTEM_ROOT;
|
|
523
559
|
}
|
|
524
560
|
|
|
561
|
+
/** A real installation is proven by a readable, owner-matching host env. */
|
|
562
|
+
function requireHostEnvironment(root, { uid, io, refused, owner }) {
|
|
563
|
+
for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
|
|
564
|
+
const info = io.lstatSync(path.join(root, file));
|
|
565
|
+
if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
|
|
566
|
+
throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
|
|
567
|
+
}
|
|
568
|
+
if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
|
|
569
|
+
}
|
|
570
|
+
io.accessSync(path.join(root, 'config', 'nc-client.env'), fs.constants.R_OK);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* A rejected/expired enrollment leaves only the device key and enrollment
|
|
575
|
+
* state (plus this process's own CLI lock) in config/. A host environment, an
|
|
576
|
+
* app payload or any unknown file means this is not the narrow recoverable
|
|
577
|
+
* shape, so removal fails closed.
|
|
578
|
+
*/
|
|
579
|
+
function requirePartialEnrollment(root, { uid, io, refused, owner }) {
|
|
580
|
+
const config = path.join(root, 'config');
|
|
581
|
+
const info = io.lstatSync(config);
|
|
582
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw refused('the config marker is missing or not a regular directory');
|
|
583
|
+
if (info.uid !== uid) throw refused(`the config marker is not owned by ${owner}`);
|
|
584
|
+
const names = io.readdirSync(config);
|
|
585
|
+
for (const name of names) {
|
|
586
|
+
if (!PARTIAL_ENROLLMENT_CONFIG_ENTRIES.has(name)) throw refused(`unexpected config entry "${name}"`);
|
|
587
|
+
const entry = io.lstatSync(path.join(config, name));
|
|
588
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw refused(`config entry "${name}" is not a regular file`);
|
|
589
|
+
if (entry.uid !== uid) throw refused(`config entry "${name}" is not owned by ${owner}`);
|
|
590
|
+
}
|
|
591
|
+
for (const name of PARTIAL_ENROLLMENT_REQUIRED) {
|
|
592
|
+
if (!names.includes(name)) throw refused(`the partial-enrollment marker "${name}" is missing`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
525
596
|
/** Shared bounded removal of an already-authorized root. */
|
|
526
|
-
function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
|
|
597
|
+
function removeBoundedRoot(root, { uid, allowedUids = null, allowedEntries, io, refused, owner, verifyConfig = requireHostEnvironment }) {
|
|
527
598
|
const resolved = path.resolve(root);
|
|
528
599
|
if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
|
|
529
600
|
const rootInfo = io.lstatSync(resolved);
|
|
@@ -534,16 +605,9 @@ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
|
|
|
534
605
|
if (!allowedEntries.has(name)) throw refused(`unexpected top-level entry "${name}"`);
|
|
535
606
|
const info = io.lstatSync(path.join(resolved, name));
|
|
536
607
|
if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
|
|
537
|
-
if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
|
|
608
|
+
if (allowedUids ? !allowedUids.has(info.uid) : info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
|
|
538
609
|
}
|
|
539
|
-
|
|
540
|
-
const info = io.lstatSync(path.join(resolved, file));
|
|
541
|
-
if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
|
|
542
|
-
throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
|
|
543
|
-
}
|
|
544
|
-
if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
|
|
545
|
-
}
|
|
546
|
-
io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
|
|
610
|
+
verifyConfig(resolved, { uid, io, refused, owner });
|
|
547
611
|
for (const name of entries) {
|
|
548
612
|
io.rmSync(path.join(resolved, name), { recursive: true, force: true });
|
|
549
613
|
}
|
|
@@ -609,12 +673,22 @@ export function removePartialSystemRoot(root, { platform = os.platform(), getuid
|
|
|
609
673
|
}
|
|
610
674
|
throw refused(`${file} exists; a real installation requires its packaged maintenance`);
|
|
611
675
|
}
|
|
612
|
-
|
|
676
|
+
const resolved = path.resolve(root);
|
|
677
|
+
const hostEnvMarker = path.join(resolved, 'config', 'nc-client.env');
|
|
678
|
+
let verifyConfig = requireHostEnvironment;
|
|
679
|
+
try {
|
|
680
|
+
io.lstatSync(hostEnvMarker);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (err?.code !== 'ENOENT') throw refused(`the host environment marker at ${hostEnvMarker} cannot be verified`);
|
|
683
|
+
verifyConfig = requirePartialEnrollment;
|
|
684
|
+
}
|
|
685
|
+
removeBoundedRoot(resolved, {
|
|
613
686
|
uid: 0,
|
|
614
687
|
allowedEntries: PARTIAL_SYSTEM_ENTRIES,
|
|
615
688
|
io,
|
|
616
689
|
refused,
|
|
617
|
-
owner: 'root'
|
|
690
|
+
owner: 'root',
|
|
691
|
+
verifyConfig
|
|
618
692
|
});
|
|
619
693
|
} catch (err) {
|
|
620
694
|
if (err?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw err;
|
|
@@ -622,6 +696,297 @@ export function removePartialSystemRoot(root, { platform = os.platform(), getuid
|
|
|
622
696
|
}
|
|
623
697
|
}
|
|
624
698
|
|
|
699
|
+
/** Top-level entries a real system install may contain. */
|
|
700
|
+
const INCOMPLETE_SYSTEM_ENTRIES = new Set([
|
|
701
|
+
'app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'libexec', 'uninstall.sh', 'install-linux.sh'
|
|
702
|
+
]);
|
|
703
|
+
|
|
704
|
+
const INCOMPLETE_SYSTEM_LAUNCHER = '/opt/nc/app/launcher.mjs';
|
|
705
|
+
const INCOMPLETE_SYSTEM_NODE = '/opt/nc/tools/node/bin/node';
|
|
706
|
+
const INCOMPLETE_SYSTEM_HOST_ENV = '/opt/nc/config/nc-client.env';
|
|
707
|
+
const INCOMPLETE_SYSTEM_UNIT = '/etc/systemd/system/nc-client.service';
|
|
708
|
+
const INCOMPLETE_SYSTEM_UNIT_DROPIN = '/etc/systemd/system/nc-client.service.d';
|
|
709
|
+
const INCOMPLETE_HELPER_APP = '/opt/nc/libexec/privileged-helper';
|
|
710
|
+
const INCOMPLETE_HELPER_DAEMON = INCOMPLETE_HELPER_APP + '/privileged-helper-linux-daemon.mjs';
|
|
711
|
+
const INCOMPLETE_HELPER_PROFILE = INCOMPLETE_HELPER_APP + '/privileged-helper-profile.json';
|
|
712
|
+
const INCOMPLETE_HELPER_UNIT = '/etc/systemd/system/nc-privileged-helper.service';
|
|
713
|
+
const INCOMPLETE_HELPER_UNIT_DROPIN = '/etc/systemd/system/nc-privileged-helper.service.d';
|
|
714
|
+
const INCOMPLETE_HELPER_SOCKET = '/etc/systemd/system/nc-privileged-helper.socket';
|
|
715
|
+
const INCOMPLETE_HELPER_SOCKET_DROPIN = '/etc/systemd/system/nc-privileged-helper.socket.d';
|
|
716
|
+
const INCOMPLETE_HELPER_TMPFILES = '/etc/tmpfiles.d/nc-privileged-helper.conf';
|
|
717
|
+
const INCOMPLETE_HELPER_SOCKET_PATH = '/run/nc/privileged-helper-v1.sock';
|
|
718
|
+
const INCOMPLETE_SYSTEM_LIFECYCLE = '/var/lib/nc-lifecycle';
|
|
719
|
+
const INCOMPLETE_SYSTEM_PAIRED_ROOT = '/var/lib/nodecommand/nc';
|
|
720
|
+
|
|
721
|
+
const SYSTEM_BOOTSTRAP_FIELDS = [
|
|
722
|
+
'client_id', 'control_plane_id', 'tenant_id', 'root_consent', 'ack_private_key_file', 'helper_key_id',
|
|
723
|
+
'helper_key_binding_id', 'binding_state', 'node_bin', 'ordinary_root', 'ordinary_service_scope', 'lifecycle_custody_state'
|
|
724
|
+
];
|
|
725
|
+
|
|
726
|
+
const systemLstat = (io) => io.lstatSync || fs.lstatSync;
|
|
727
|
+
const systemRealpath = (io) => io.realpathSync || fs.realpathSync;
|
|
728
|
+
const systemReaddir = (io) => io.readdirSync || fs.readdirSync;
|
|
729
|
+
|
|
730
|
+
function systemLstatOrNull(io, file) {
|
|
731
|
+
try { return systemLstat(io)(file); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Read a fixed protected file with root-only metadata and an exclusive fd. */
|
|
735
|
+
function readSystemFile(io, file, { uids = new Set([0]), mode = null, max = 65536 } = {}) {
|
|
736
|
+
const info = systemLstat(io)(file);
|
|
737
|
+
if (info.isSymbolicLink() || !info.isFile() || !uids.has(info.uid) || info.nlink !== 1 ||
|
|
738
|
+
(info.mode & 0o022) || (mode !== null && (info.mode & 0o7777) !== mode) ||
|
|
739
|
+
!Number.isSafeInteger(info.size) || info.size <= 0 || info.size > max) throw new Error('unprotected');
|
|
740
|
+
if (systemRealpath(io)(file) !== file) throw new Error('noncanonical');
|
|
741
|
+
const fd = (io.openSync || fs.openSync)(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
742
|
+
try {
|
|
743
|
+
const checked = (io.fstatSync || fs.fstatSync)(fd);
|
|
744
|
+
if (checked.dev !== info.dev || checked.ino !== info.ino || checked.size !== info.size ||
|
|
745
|
+
checked.uid !== info.uid || checked.nlink !== 1) throw new Error('changed');
|
|
746
|
+
const text = String((io.readFileSync || fs.readFileSync)(fd, 'utf8'));
|
|
747
|
+
if (Buffer.byteLength(text) !== info.size) throw new Error('changed');
|
|
748
|
+
return text;
|
|
749
|
+
} finally { (io.closeSync || fs.closeSync)(fd); }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const systemUnitValues = (text, key) => text.split(/\r?\n/)
|
|
753
|
+
.filter((line) => line.startsWith(key + '=')).map((line) => line.slice(key.length + 1));
|
|
754
|
+
|
|
755
|
+
function systemExecStart(value) {
|
|
756
|
+
const match = /^(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))\s*$/.exec(value);
|
|
757
|
+
return match ? { runtime: match[1] || match[2], launcher: match[3] || match[4] } : null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const systemCanonical = (value) => typeof value === 'string' && path.isAbsolute(value) &&
|
|
761
|
+
path.resolve(value) === value && !/[\x00-\x1f\x7f]/.test(value);
|
|
762
|
+
|
|
763
|
+
/** A unit runtime may be the canonical path or an equivalent symlink to it. */
|
|
764
|
+
function systemRuntimeMatches(io, actual, runtime) {
|
|
765
|
+
if (actual === runtime) return true;
|
|
766
|
+
if (!systemCanonical(actual)) return false;
|
|
767
|
+
try { return systemRealpath(io)(actual) === runtime; } catch { return false; }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Verify the exact ordinary client unit this tree produced. */
|
|
771
|
+
function systemValidateClientUnit(io, runtime) {
|
|
772
|
+
if (systemLstatOrNull(io, INCOMPLETE_SYSTEM_UNIT_DROPIN)) throw new Error('unit drop-in');
|
|
773
|
+
const text = readSystemFile(io, INCOMPLETE_SYSTEM_UNIT);
|
|
774
|
+
const directories = systemUnitValues(text, 'WorkingDirectory');
|
|
775
|
+
const launches = systemUnitValues(text, 'ExecStart');
|
|
776
|
+
const launch = launches.length === 1 ? systemExecStart(launches[0]) : null;
|
|
777
|
+
if (directories.length !== 1 || !['/opt/nc', '/opt/nc/app'].includes(directories[0]) ||
|
|
778
|
+
!launch || launch.launcher !== INCOMPLETE_SYSTEM_LAUNCHER || !systemRuntimeMatches(io, launch.runtime, runtime))
|
|
779
|
+
throw new Error('foreign client unit');
|
|
780
|
+
for (const key of ['User', 'Group']) {
|
|
781
|
+
const values = systemUnitValues(text, key);
|
|
782
|
+
if (values.length !== 1 || values[0] !== 'nodecommand') throw new Error('foreign client identity');
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Verify the exact helper socket/service/tmpfiles that point at this tree. */
|
|
787
|
+
function systemValidateHelperArtifacts(io, runtime) {
|
|
788
|
+
if (systemLstatOrNull(io, INCOMPLETE_HELPER_UNIT_DROPIN) || systemLstatOrNull(io, INCOMPLETE_HELPER_SOCKET_DROPIN))
|
|
789
|
+
throw new Error('helper drop-in');
|
|
790
|
+
const service = systemLstatOrNull(io, INCOMPLETE_HELPER_UNIT);
|
|
791
|
+
const socket = systemLstatOrNull(io, INCOMPLETE_HELPER_SOCKET);
|
|
792
|
+
if (!!service !== !!socket) throw new Error('incomplete helper unit pair');
|
|
793
|
+
let group = null;
|
|
794
|
+
if (service) {
|
|
795
|
+
const text = readSystemFile(io, INCOMPLETE_HELPER_UNIT);
|
|
796
|
+
const directories = systemUnitValues(text, 'WorkingDirectory');
|
|
797
|
+
const launches = systemUnitValues(text, 'ExecStart');
|
|
798
|
+
const launch = launches.length === 1 ? systemExecStart(launches[0]) : null;
|
|
799
|
+
if (directories.length !== 1 || directories[0] !== INCOMPLETE_HELPER_APP ||
|
|
800
|
+
!launch || launch.launcher !== INCOMPLETE_HELPER_DAEMON || !systemRuntimeMatches(io, launch.runtime, runtime))
|
|
801
|
+
throw new Error('foreign helper unit');
|
|
802
|
+
for (const key of ['User', 'Group']) {
|
|
803
|
+
const values = systemUnitValues(text, key);
|
|
804
|
+
if (values.length !== 1 || values[0] !== 'root') throw new Error('foreign helper identity');
|
|
805
|
+
}
|
|
806
|
+
const socketText = readSystemFile(io, INCOMPLETE_HELPER_SOCKET);
|
|
807
|
+
const listen = systemUnitValues(socketText, 'ListenStream');
|
|
808
|
+
const services = systemUnitValues(socketText, 'Service');
|
|
809
|
+
const groups = systemUnitValues(socketText, 'SocketGroup');
|
|
810
|
+
const removeOnStop = systemUnitValues(socketText, 'RemoveOnStop');
|
|
811
|
+
if (listen.length !== 1 || listen[0] !== INCOMPLETE_HELPER_SOCKET_PATH || services.length > 1 ||
|
|
812
|
+
(services[0] !== undefined && services[0] !== 'nc-privileged-helper.service') ||
|
|
813
|
+
groups.length !== 1 || !/^[a-z_][a-z0-9_-]*[$]?$/.test(groups[0]) ||
|
|
814
|
+
removeOnStop.length !== 1 || removeOnStop[0] !== 'true') throw new Error('foreign helper socket');
|
|
815
|
+
group = groups[0];
|
|
816
|
+
}
|
|
817
|
+
const tmpfiles = systemLstatOrNull(io, INCOMPLETE_HELPER_TMPFILES);
|
|
818
|
+
if (tmpfiles) {
|
|
819
|
+
if (!group) throw new Error('orphan helper tmpfiles');
|
|
820
|
+
const text = readSystemFile(io, INCOMPLETE_HELPER_TMPFILES);
|
|
821
|
+
if (text.trim() !== `d /run/nc 0750 root ${group} -`) throw new Error('foreign helper tmpfiles');
|
|
822
|
+
}
|
|
823
|
+
return { service: !!service, tmpfiles: !!tmpfiles };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Recognize the two root-recoverable system-install leftovers this CLI may
|
|
828
|
+
* remove without the packaged maintenance entry: a helper provisioned but
|
|
829
|
+
* never bound (`pending`/`not_initialized`, no profile, no lifecycle custody)
|
|
830
|
+
* and a failed install whose app payload never landed but whose stale unit
|
|
831
|
+
* remains. Anything else returns null so callers keep failing closed.
|
|
832
|
+
* @param {string} root
|
|
833
|
+
* @param {{ platform?: string, io?: typeof fs }} [options]
|
|
834
|
+
* @returns {null | { kind: 'pending-helper'|'failed-install', runtime: string, payloadUid: number, helperArtifacts: { service: boolean, tmpfiles: boolean } }}
|
|
835
|
+
*/
|
|
836
|
+
export function incompleteSystemInstallationShape(root, { platform = os.platform(), io = fs } = {}) {
|
|
837
|
+
if (platform !== 'linux' || path.resolve(root) !== PARTIAL_SYSTEM_ROOT) return null;
|
|
838
|
+
try {
|
|
839
|
+
const lstat = systemLstat(io);
|
|
840
|
+
if (systemRealpath(io)(root) !== root) return null;
|
|
841
|
+
const rootInfo = lstat(root);
|
|
842
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory() || rootInfo.uid !== 0 || (rootInfo.mode & 0o022)) return null;
|
|
843
|
+
const names = systemReaddir(io)(root);
|
|
844
|
+
if (names.length === 0 || !names.every((name) => INCOMPLETE_SYSTEM_ENTRIES.has(name))) return null;
|
|
845
|
+
const appInfo = systemLstatOrNull(io, path.join(root, 'app'));
|
|
846
|
+
if (!appInfo || appInfo.isSymbolicLink() || !appInfo.isDirectory()) return null;
|
|
847
|
+
let payloadUid = appInfo.uid;
|
|
848
|
+
const configInfo = systemLstatOrNull(io, path.join(root, 'config'));
|
|
849
|
+
if (configInfo) {
|
|
850
|
+
if (configInfo.isSymbolicLink() || !configInfo.isDirectory()) return null;
|
|
851
|
+
payloadUid = configInfo.uid;
|
|
852
|
+
}
|
|
853
|
+
for (const name of names) {
|
|
854
|
+
const info = lstat(path.join(root, name));
|
|
855
|
+
if (info.isSymbolicLink() || (info.uid !== 0 && info.uid !== payloadUid)) return null;
|
|
856
|
+
}
|
|
857
|
+
const helper = systemLstatOrNull(io, INCOMPLETE_HELPER_APP);
|
|
858
|
+
if (helper) {
|
|
859
|
+
if (helper.isSymbolicLink() || !helper.isDirectory() || helper.uid !== 0 || (helper.mode & 0o022)) return null;
|
|
860
|
+
// A bound/prepared helper, external lifecycle custody or the other
|
|
861
|
+
// ordinary root all require their own trusted maintenance.
|
|
862
|
+
if (systemLstatOrNull(io, INCOMPLETE_HELPER_PROFILE) || systemLstatOrNull(io, INCOMPLETE_SYSTEM_LIFECYCLE) ||
|
|
863
|
+
systemLstatOrNull(io, INCOMPLETE_SYSTEM_PAIRED_ROOT)) return null;
|
|
864
|
+
const bootstrap = JSON.parse(readSystemFile(io, INCOMPLETE_HELPER_APP + '/helper-bootstrap.json', { mode: 0o600 }));
|
|
865
|
+
const controlPlaneId = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 &&
|
|
866
|
+
value.trim() === value && !/[\x00-\x1f\x7f]/.test(value);
|
|
867
|
+
const id = (value) => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value);
|
|
868
|
+
if (!bootstrap || typeof bootstrap !== 'object' || Array.isArray(bootstrap) ||
|
|
869
|
+
Object.keys(bootstrap).length !== SYSTEM_BOOTSTRAP_FIELDS.length ||
|
|
870
|
+
!SYSTEM_BOOTSTRAP_FIELDS.every((field) => Object.hasOwn(bootstrap, field)) ||
|
|
871
|
+
!/^nc_[0-9a-f]{64}$/.test(bootstrap.client_id) || !controlPlaneId(bootstrap.control_plane_id) ||
|
|
872
|
+
!id(bootstrap.tenant_id) || !id(bootstrap.helper_key_id) || bootstrap.root_consent !== true ||
|
|
873
|
+
bootstrap.helper_key_binding_id !== null || bootstrap.binding_state !== 'pending' ||
|
|
874
|
+
bootstrap.lifecycle_custody_state !== 'not_initialized' || bootstrap.ordinary_root !== root ||
|
|
875
|
+
bootstrap.ordinary_service_scope !== 'system' ||
|
|
876
|
+
bootstrap.ack_private_key_file !== INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem' ||
|
|
877
|
+
!systemCanonical(bootstrap.node_bin)) return null;
|
|
878
|
+
readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper.json', { mode: 0o600 });
|
|
879
|
+
readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem', { mode: 0o600 });
|
|
880
|
+
readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-public.pem', { mode: 0o644 });
|
|
881
|
+
const peercred = systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred');
|
|
882
|
+
if (!peercred || peercred.isSymbolicLink() || !peercred.isFile() || peercred.uid !== 0 || peercred.nlink !== 1) return null;
|
|
883
|
+
const daemon = systemLstatOrNull(io, INCOMPLETE_HELPER_DAEMON);
|
|
884
|
+
if (!daemon || daemon.isSymbolicLink() || !daemon.isFile() || daemon.uid !== 0 || !(daemon.mode & 0o111)) return null;
|
|
885
|
+
readSystemFile(io, INCOMPLETE_SYSTEM_LAUNCHER, { uids: new Set([0, payloadUid]) });
|
|
886
|
+
const hostEnv = readSystemFile(io, INCOMPLETE_SYSTEM_HOST_ENV, { uids: new Set([0, payloadUid]) });
|
|
887
|
+
const ids = hostEnv.split(/\r?\n/).filter((line) => /^\s*NC_CLIENT_ID\s*=/.test(line));
|
|
888
|
+
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*$/);
|
|
889
|
+
if (ids.length !== 1 || !clientId || (clientId[1] || clientId[2] || clientId[3]) !== bootstrap.client_id) return null;
|
|
890
|
+
systemValidateClientUnit(io, bootstrap.node_bin);
|
|
891
|
+
const helperArtifacts = systemValidateHelperArtifacts(io, bootstrap.node_bin);
|
|
892
|
+
return Object.freeze({ kind: 'pending-helper', runtime: bootstrap.node_bin, payloadUid, helperArtifacts });
|
|
893
|
+
}
|
|
894
|
+
// Failed-install shape: no helper payload, no packaged uninstaller, and an
|
|
895
|
+
// exactly matching stale unit whose launcher never landed (or is empty).
|
|
896
|
+
if (systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred')) return null;
|
|
897
|
+
if (systemLstatOrNull(io, path.join(root, 'uninstall.sh'))) return null;
|
|
898
|
+
for (const artifact of [INCOMPLETE_HELPER_UNIT, INCOMPLETE_HELPER_SOCKET, INCOMPLETE_HELPER_TMPFILES])
|
|
899
|
+
if (systemLstatOrNull(io, artifact)) return null;
|
|
900
|
+
if (systemLstatOrNull(io, INCOMPLETE_SYSTEM_LIFECYCLE) || systemLstatOrNull(io, INCOMPLETE_SYSTEM_PAIRED_ROOT)) return null;
|
|
901
|
+
const libexec = systemLstatOrNull(io, path.join(root, 'libexec'));
|
|
902
|
+
if (libexec) {
|
|
903
|
+
if (libexec.isSymbolicLink() || !libexec.isDirectory() || systemReaddir(io)(path.join(root, 'libexec')).length !== 0) return null;
|
|
904
|
+
}
|
|
905
|
+
const launcher = systemLstatOrNull(io, INCOMPLETE_SYSTEM_LAUNCHER);
|
|
906
|
+
if (launcher && (launcher.isSymbolicLink() || !launcher.isFile() || launcher.size !== 0 ||
|
|
907
|
+
(launcher.uid !== 0 && launcher.uid !== payloadUid))) return null;
|
|
908
|
+
systemValidateClientUnit(io, INCOMPLETE_SYSTEM_NODE);
|
|
909
|
+
return Object.freeze({
|
|
910
|
+
kind: 'failed-install',
|
|
911
|
+
runtime: INCOMPLETE_SYSTEM_NODE,
|
|
912
|
+
payloadUid,
|
|
913
|
+
helperArtifacts: Object.freeze({ service: false, tmpfiles: false })
|
|
914
|
+
});
|
|
915
|
+
} catch {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Root-only removal of the exact incomplete system shapes above. Stops and
|
|
922
|
+
* disables the selected client, removes only the exact helper artifacts that
|
|
923
|
+
* point at /opt/nc, then removes the bounded tree. The dedicated nodecommand
|
|
924
|
+
* account is never touched, and no other path is eligible.
|
|
925
|
+
* @param {string} root
|
|
926
|
+
* @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs, run?: Function }} [options]
|
|
927
|
+
* @returns {'pending-helper'|'failed-install'}
|
|
928
|
+
*/
|
|
929
|
+
export function removeIncompleteSystemInstallation(root, {
|
|
930
|
+
platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid, io = fs, run = child_process.spawnSync
|
|
931
|
+
} = {}) {
|
|
932
|
+
const refused = (reason) => Object.assign(
|
|
933
|
+
new Error(`Refusing incomplete system removal of ${root}: ${reason}.`),
|
|
934
|
+
{ code: 'PARTIAL_SYSTEM_REMOVAL_REFUSED' }
|
|
935
|
+
);
|
|
936
|
+
if (!isPartialSystemRemovalCandidate(root, { platform, getuid, geteuid })) {
|
|
937
|
+
throw refused('it is not the canonical incomplete system root');
|
|
938
|
+
}
|
|
939
|
+
const shape = incompleteSystemInstallationShape(root, { platform, io });
|
|
940
|
+
if (!shape) throw refused('the system tree is not the exact pending-helper or failed-install shape');
|
|
941
|
+
const unlink = io.unlinkSync || fs.unlinkSync;
|
|
942
|
+
const systemctl = (args) => {
|
|
943
|
+
const result = run('/usr/bin/systemctl', ['--system', '--no-pager', '--no-ask-password', ...args], {
|
|
944
|
+
env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C', LC_ALL: 'C' }, cwd: '/', encoding: 'utf8',
|
|
945
|
+
timeout: 15000, stdio: ['ignore', 'pipe', 'pipe']
|
|
946
|
+
});
|
|
947
|
+
if (!result || result.error || result.signal || result.status !== 0) throw refused(`systemctl ${args.join(' ')} failed`);
|
|
948
|
+
return String(result.stdout ?? '');
|
|
949
|
+
};
|
|
950
|
+
try {
|
|
951
|
+
systemctl(['disable', '--now', 'nc-client.service']);
|
|
952
|
+
if (shape.kind === 'pending-helper' && shape.helperArtifacts.service) {
|
|
953
|
+
systemctl(['disable', '--now', 'nc-privileged-helper.socket']);
|
|
954
|
+
systemctl(['stop', 'nc-privileged-helper.service']);
|
|
955
|
+
try {
|
|
956
|
+
systemLstat(io)(INCOMPLETE_HELPER_SOCKET_PATH);
|
|
957
|
+
throw refused('the privileged helper socket is still present after stop');
|
|
958
|
+
} catch (error) {
|
|
959
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
960
|
+
}
|
|
961
|
+
if (shape.helperArtifacts.tmpfiles) unlink(INCOMPLETE_HELPER_TMPFILES);
|
|
962
|
+
unlink(INCOMPLETE_HELPER_SOCKET);
|
|
963
|
+
unlink(INCOMPLETE_HELPER_UNIT);
|
|
964
|
+
}
|
|
965
|
+
unlink(INCOMPLETE_SYSTEM_UNIT);
|
|
966
|
+
systemctl(['daemon-reload']);
|
|
967
|
+
const units = shape.kind === 'pending-helper' && shape.helperArtifacts.service
|
|
968
|
+
? ['nc-client.service', 'nc-privileged-helper.socket', 'nc-privileged-helper.service']
|
|
969
|
+
: ['nc-client.service'];
|
|
970
|
+
for (const unit of units) {
|
|
971
|
+
const state = systemctl(['show', unit, '--property=LoadState', '--value']).trim();
|
|
972
|
+
if (state !== 'not-found') throw refused(`${unit} is still loaded as ${state}`);
|
|
973
|
+
}
|
|
974
|
+
removeBoundedRoot(root, {
|
|
975
|
+
uid: 0,
|
|
976
|
+
allowedUids: new Set([0, shape.payloadUid]),
|
|
977
|
+
allowedEntries: INCOMPLETE_SYSTEM_ENTRIES,
|
|
978
|
+
io,
|
|
979
|
+
refused,
|
|
980
|
+
owner: 'root',
|
|
981
|
+
verifyConfig: () => {}
|
|
982
|
+
});
|
|
983
|
+
return shape.kind;
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (error?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw error;
|
|
986
|
+
throw refused(error instanceof Error ? error.message : 'the tree could not be verified');
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
625
990
|
/**
|
|
626
991
|
* Canonical machine-wide roots that exist but cannot be inspected by the
|
|
627
992
|
* current unprivileged caller. Setup must escalate before deciding anything
|
|
@@ -699,16 +1064,21 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
|
699
1064
|
const install = [];
|
|
700
1065
|
const unreadable = [];
|
|
701
1066
|
let pair = null;
|
|
1067
|
+
let incompleteRoot = null;
|
|
702
1068
|
if ((rest.platform || os.platform()) === 'linux') {
|
|
1069
|
+
let pairedError = null;
|
|
703
1070
|
try {
|
|
704
1071
|
pair = linuxPairedInstallation(io);
|
|
705
1072
|
} catch (err) {
|
|
706
1073
|
// Protected metadata this account cannot stat or read is not a local
|
|
707
|
-
// removal target.
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1074
|
+
// removal target. Any other validation failure remains a hard stop
|
|
1075
|
+
// unless the tree is the exact recoverable pending/failed shape.
|
|
1076
|
+
if (err?.code !== 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw err;
|
|
1077
|
+
pairedError = err;
|
|
1078
|
+
}
|
|
1079
|
+
if (!pair) {
|
|
1080
|
+
if (incompleteSystemInstallationShape(PARTIAL_SYSTEM_ROOT, { platform: 'linux', io })) incompleteRoot = PARTIAL_SYSTEM_ROOT;
|
|
1081
|
+
if (pairedError && !incompleteRoot && !pairedMetadataUnreadable(io)) throw pairedError;
|
|
712
1082
|
}
|
|
713
1083
|
}
|
|
714
1084
|
const env = rest.env || process.env;
|
|
@@ -719,6 +1089,15 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
|
719
1089
|
const status = probeRemovalRoot(root, io);
|
|
720
1090
|
if (status === 'install' && !install.includes(root)) install.push(root);
|
|
721
1091
|
else if (status === 'unreadable' && !unreadable.includes(root)) unreadable.push(root);
|
|
1092
|
+
else if (status === 'foreign' && !install.includes(root) && isPartialSystemRemovalCandidate(root, rest) &&
|
|
1093
|
+
partialEnrollmentSystemRoot(root, io)) install.push(root);
|
|
1094
|
+
}
|
|
1095
|
+
// The exact pending-helper / failed-install system tree is a bounded target
|
|
1096
|
+
// only for a real+effective root caller; an ordinary caller gets the
|
|
1097
|
+
// trusted-maintenance message instead of a silent "nothing to remove".
|
|
1098
|
+
if (incompleteRoot && !install.includes(incompleteRoot) && !unreadable.includes(incompleteRoot)) {
|
|
1099
|
+
if (isPartialSystemRemovalCandidate(incompleteRoot, rest)) install.push(incompleteRoot);
|
|
1100
|
+
else unreadable.push(incompleteRoot);
|
|
722
1101
|
}
|
|
723
1102
|
// The paired relation is only actionable when its root is itself readable.
|
|
724
1103
|
// An unverifiable/foreign paired root is reported through `unreadable`.
|