node-command 2.0.12 → 2.0.14
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 +342 -7
- 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
|
@@ -594,7 +594,7 @@ function requirePartialEnrollment(root, { uid, io, refused, owner }) {
|
|
|
594
594
|
}
|
|
595
595
|
|
|
596
596
|
/** Shared bounded removal of an already-authorized root. */
|
|
597
|
-
function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner, verifyConfig = requireHostEnvironment }) {
|
|
597
|
+
function removeBoundedRoot(root, { uid, allowedUids = null, allowedEntries, io, refused, owner, verifyConfig = requireHostEnvironment }) {
|
|
598
598
|
const resolved = path.resolve(root);
|
|
599
599
|
if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
|
|
600
600
|
const rootInfo = io.lstatSync(resolved);
|
|
@@ -605,7 +605,7 @@ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner, veri
|
|
|
605
605
|
if (!allowedEntries.has(name)) throw refused(`unexpected top-level entry "${name}"`);
|
|
606
606
|
const info = io.lstatSync(path.join(resolved, name));
|
|
607
607
|
if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
|
|
608
|
-
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}`);
|
|
609
609
|
}
|
|
610
610
|
verifyConfig(resolved, { uid, io, refused, owner });
|
|
611
611
|
for (const name of entries) {
|
|
@@ -696,6 +696,329 @@ export function removePartialSystemRoot(root, { platform = os.platform(), getuid
|
|
|
696
696
|
}
|
|
697
697
|
}
|
|
698
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_BOOTSTRAP = '/opt/nc/libexec/protected-helper-bootstrap';
|
|
711
|
+
const INCOMPLETE_HELPER_BOOTSTRAP_ENTRIES = new Map([
|
|
712
|
+
['archive.tar.gz', 'file'],
|
|
713
|
+
['install-request.json', 'file'],
|
|
714
|
+
['modules', 'directory'],
|
|
715
|
+
['release', 'directory']
|
|
716
|
+
]);
|
|
717
|
+
const INCOMPLETE_HELPER_DAEMON = INCOMPLETE_HELPER_APP + '/privileged-helper-linux-daemon.mjs';
|
|
718
|
+
const INCOMPLETE_HELPER_PROFILE = INCOMPLETE_HELPER_APP + '/privileged-helper-profile.json';
|
|
719
|
+
const INCOMPLETE_HELPER_UNIT = '/etc/systemd/system/nc-privileged-helper.service';
|
|
720
|
+
const INCOMPLETE_HELPER_UNIT_DROPIN = '/etc/systemd/system/nc-privileged-helper.service.d';
|
|
721
|
+
const INCOMPLETE_HELPER_SOCKET = '/etc/systemd/system/nc-privileged-helper.socket';
|
|
722
|
+
const INCOMPLETE_HELPER_SOCKET_DROPIN = '/etc/systemd/system/nc-privileged-helper.socket.d';
|
|
723
|
+
const INCOMPLETE_HELPER_TMPFILES = '/etc/tmpfiles.d/nc-privileged-helper.conf';
|
|
724
|
+
const INCOMPLETE_HELPER_SOCKET_PATH = '/run/nc/privileged-helper-v1.sock';
|
|
725
|
+
const INCOMPLETE_SYSTEM_LIFECYCLE = '/var/lib/nc-lifecycle';
|
|
726
|
+
const INCOMPLETE_SYSTEM_PAIRED_ROOT = '/var/lib/nodecommand/nc';
|
|
727
|
+
|
|
728
|
+
const SYSTEM_BOOTSTRAP_FIELDS = [
|
|
729
|
+
'client_id', 'control_plane_id', 'tenant_id', 'root_consent', 'ack_private_key_file', 'helper_key_id',
|
|
730
|
+
'helper_key_binding_id', 'binding_state', 'node_bin', 'ordinary_root', 'ordinary_service_scope', 'lifecycle_custody_state'
|
|
731
|
+
];
|
|
732
|
+
|
|
733
|
+
const systemLstat = (io) => io.lstatSync || fs.lstatSync;
|
|
734
|
+
const systemRealpath = (io) => io.realpathSync || fs.realpathSync;
|
|
735
|
+
const systemReaddir = (io) => io.readdirSync || fs.readdirSync;
|
|
736
|
+
|
|
737
|
+
function systemLstatOrNull(io, file) {
|
|
738
|
+
try { return systemLstat(io)(file); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Read a fixed protected file with root-only metadata and an exclusive fd. */
|
|
742
|
+
function readSystemFile(io, file, { uids = new Set([0]), mode = null, max = 65536 } = {}) {
|
|
743
|
+
const info = systemLstat(io)(file);
|
|
744
|
+
if (info.isSymbolicLink() || !info.isFile() || !uids.has(info.uid) || info.nlink !== 1 ||
|
|
745
|
+
(info.mode & 0o022) || (mode !== null && (info.mode & 0o7777) !== mode) ||
|
|
746
|
+
!Number.isSafeInteger(info.size) || info.size <= 0 || info.size > max) throw new Error('unprotected');
|
|
747
|
+
if (systemRealpath(io)(file) !== file) throw new Error('noncanonical');
|
|
748
|
+
const fd = (io.openSync || fs.openSync)(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
749
|
+
try {
|
|
750
|
+
const checked = (io.fstatSync || fs.fstatSync)(fd);
|
|
751
|
+
if (checked.dev !== info.dev || checked.ino !== info.ino || checked.size !== info.size ||
|
|
752
|
+
checked.uid !== info.uid || checked.nlink !== 1) throw new Error('changed');
|
|
753
|
+
const text = String((io.readFileSync || fs.readFileSync)(fd, 'utf8'));
|
|
754
|
+
if (Buffer.byteLength(text) !== info.size) throw new Error('changed');
|
|
755
|
+
return text;
|
|
756
|
+
} finally { (io.closeSync || fs.closeSync)(fd); }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const systemUnitValues = (text, key) => text.split(/\r?\n/)
|
|
760
|
+
.filter((line) => line.startsWith(key + '=')).map((line) => line.slice(key.length + 1));
|
|
761
|
+
|
|
762
|
+
function systemExecStart(value) {
|
|
763
|
+
const match = /^(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))\s*$/.exec(value);
|
|
764
|
+
return match ? { runtime: match[1] || match[2], launcher: match[3] || match[4] } : null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const systemCanonical = (value) => typeof value === 'string' && path.isAbsolute(value) &&
|
|
768
|
+
path.resolve(value) === value && !/[\x00-\x1f\x7f]/.test(value);
|
|
769
|
+
|
|
770
|
+
/** A unit runtime may be the canonical path or an equivalent symlink to it. */
|
|
771
|
+
function systemRuntimeMatches(io, actual, runtime) {
|
|
772
|
+
if (actual === runtime) return true;
|
|
773
|
+
if (!systemCanonical(actual)) return false;
|
|
774
|
+
try { return systemRealpath(io)(actual) === runtime; } catch { return false; }
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Verify the exact ordinary client unit this tree produced. */
|
|
778
|
+
function systemValidateClientUnit(io, runtime) {
|
|
779
|
+
if (systemLstatOrNull(io, INCOMPLETE_SYSTEM_UNIT_DROPIN)) throw new Error('unit drop-in');
|
|
780
|
+
const text = readSystemFile(io, INCOMPLETE_SYSTEM_UNIT);
|
|
781
|
+
const directories = systemUnitValues(text, 'WorkingDirectory');
|
|
782
|
+
const launches = systemUnitValues(text, 'ExecStart');
|
|
783
|
+
const launch = launches.length === 1 ? systemExecStart(launches[0]) : null;
|
|
784
|
+
if (directories.length !== 1 || !['/opt/nc', '/opt/nc/app'].includes(directories[0]) ||
|
|
785
|
+
!launch || launch.launcher !== INCOMPLETE_SYSTEM_LAUNCHER || !systemRuntimeMatches(io, launch.runtime, runtime))
|
|
786
|
+
throw new Error('foreign client unit');
|
|
787
|
+
for (const key of ['User', 'Group']) {
|
|
788
|
+
const values = systemUnitValues(text, key);
|
|
789
|
+
if (values.length !== 1 || values[0] !== 'nodecommand') throw new Error('foreign client identity');
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Verify the exact helper socket/service/tmpfiles that point at this tree. */
|
|
794
|
+
function systemValidateHelperArtifacts(io, runtime) {
|
|
795
|
+
if (systemLstatOrNull(io, INCOMPLETE_HELPER_UNIT_DROPIN) || systemLstatOrNull(io, INCOMPLETE_HELPER_SOCKET_DROPIN))
|
|
796
|
+
throw new Error('helper drop-in');
|
|
797
|
+
const service = systemLstatOrNull(io, INCOMPLETE_HELPER_UNIT);
|
|
798
|
+
const socket = systemLstatOrNull(io, INCOMPLETE_HELPER_SOCKET);
|
|
799
|
+
if (!!service !== !!socket) throw new Error('incomplete helper unit pair');
|
|
800
|
+
let group = null;
|
|
801
|
+
if (service) {
|
|
802
|
+
const text = readSystemFile(io, INCOMPLETE_HELPER_UNIT);
|
|
803
|
+
const directories = systemUnitValues(text, 'WorkingDirectory');
|
|
804
|
+
const launches = systemUnitValues(text, 'ExecStart');
|
|
805
|
+
const launch = launches.length === 1 ? systemExecStart(launches[0]) : null;
|
|
806
|
+
if (directories.length !== 1 || directories[0] !== INCOMPLETE_HELPER_APP ||
|
|
807
|
+
!launch || launch.launcher !== INCOMPLETE_HELPER_DAEMON || !systemRuntimeMatches(io, launch.runtime, runtime))
|
|
808
|
+
throw new Error('foreign helper unit');
|
|
809
|
+
for (const key of ['User', 'Group']) {
|
|
810
|
+
const values = systemUnitValues(text, key);
|
|
811
|
+
if (values.length !== 1 || values[0] !== 'root') throw new Error('foreign helper identity');
|
|
812
|
+
}
|
|
813
|
+
const socketText = readSystemFile(io, INCOMPLETE_HELPER_SOCKET);
|
|
814
|
+
const listen = systemUnitValues(socketText, 'ListenStream');
|
|
815
|
+
const services = systemUnitValues(socketText, 'Service');
|
|
816
|
+
const groups = systemUnitValues(socketText, 'SocketGroup');
|
|
817
|
+
const removeOnStop = systemUnitValues(socketText, 'RemoveOnStop');
|
|
818
|
+
if (listen.length !== 1 || listen[0] !== INCOMPLETE_HELPER_SOCKET_PATH || services.length > 1 ||
|
|
819
|
+
(services[0] !== undefined && services[0] !== 'nc-privileged-helper.service') ||
|
|
820
|
+
groups.length !== 1 || !/^[a-z_][a-z0-9_-]*[$]?$/.test(groups[0]) ||
|
|
821
|
+
removeOnStop.length !== 1 || removeOnStop[0] !== 'true') throw new Error('foreign helper socket');
|
|
822
|
+
group = groups[0];
|
|
823
|
+
}
|
|
824
|
+
const tmpfiles = systemLstatOrNull(io, INCOMPLETE_HELPER_TMPFILES);
|
|
825
|
+
if (tmpfiles) {
|
|
826
|
+
if (!group) throw new Error('orphan helper tmpfiles');
|
|
827
|
+
const text = readSystemFile(io, INCOMPLETE_HELPER_TMPFILES);
|
|
828
|
+
if (text.trim() !== `d /run/nc 0750 root ${group} -`) throw new Error('foreign helper tmpfiles');
|
|
829
|
+
}
|
|
830
|
+
return { service: !!service, tmpfiles: !!tmpfiles };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* `libexec` is removable only while empty or holding the exact staging tree a
|
|
835
|
+
* protected helper bootstrap writes before provisioning: root-owned regular
|
|
836
|
+
* files/directories, no symlinks, no group/other write bits, and no unknown
|
|
837
|
+
* entry. A provisioned helper, peercred shim or any other content fails closed.
|
|
838
|
+
*/
|
|
839
|
+
function systemValidateStagedHelperBootstrap(io, libexec) {
|
|
840
|
+
const entries = systemReaddir(io)(libexec);
|
|
841
|
+
if (entries.length === 0) return true;
|
|
842
|
+
if (entries.length !== 1 || entries[0] !== 'protected-helper-bootstrap') return false;
|
|
843
|
+
const stage = systemLstatOrNull(io, INCOMPLETE_HELPER_BOOTSTRAP);
|
|
844
|
+
if (!stage || stage.isSymbolicLink() || !stage.isDirectory() || stage.uid !== 0 || (stage.mode & 0o022)) return false;
|
|
845
|
+
const staged = systemReaddir(io)(INCOMPLETE_HELPER_BOOTSTRAP);
|
|
846
|
+
if (staged.length !== INCOMPLETE_HELPER_BOOTSTRAP_ENTRIES.size) return false;
|
|
847
|
+
for (const [name, type] of INCOMPLETE_HELPER_BOOTSTRAP_ENTRIES) {
|
|
848
|
+
if (!staged.includes(name)) return false;
|
|
849
|
+
const info = systemLstatOrNull(io, path.join(INCOMPLETE_HELPER_BOOTSTRAP, name));
|
|
850
|
+
if (!info || info.isSymbolicLink() || info.uid !== 0 || (info.mode & 0o022)) return false;
|
|
851
|
+
if (type === 'directory' ? !info.isDirectory() : (!info.isFile() || info.nlink !== 1)) return false;
|
|
852
|
+
}
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Recognize the two root-recoverable system-install leftovers this CLI may
|
|
858
|
+
* remove without the packaged maintenance entry: a helper provisioned but
|
|
859
|
+
* never bound (`pending`/`not_initialized`, no profile, no lifecycle custody)
|
|
860
|
+
* and a failed install whose app payload never landed but whose stale unit
|
|
861
|
+
* remains. A failed install may also leave the exact root-owned protected
|
|
862
|
+
* helper bootstrap staged but never provisioned. Anything else returns null so
|
|
863
|
+
* callers keep failing closed.
|
|
864
|
+
* @param {string} root
|
|
865
|
+
* @param {{ platform?: string, io?: typeof fs }} [options]
|
|
866
|
+
* @returns {null | { kind: 'pending-helper'|'failed-install', runtime: string, payloadUid: number, helperArtifacts: { service: boolean, tmpfiles: boolean } }}
|
|
867
|
+
*/
|
|
868
|
+
export function incompleteSystemInstallationShape(root, { platform = os.platform(), io = fs } = {}) {
|
|
869
|
+
if (platform !== 'linux' || path.resolve(root) !== PARTIAL_SYSTEM_ROOT) return null;
|
|
870
|
+
try {
|
|
871
|
+
const lstat = systemLstat(io);
|
|
872
|
+
if (systemRealpath(io)(root) !== root) return null;
|
|
873
|
+
const rootInfo = lstat(root);
|
|
874
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory() || rootInfo.uid !== 0 || (rootInfo.mode & 0o022)) return null;
|
|
875
|
+
const names = systemReaddir(io)(root);
|
|
876
|
+
if (names.length === 0 || !names.every((name) => INCOMPLETE_SYSTEM_ENTRIES.has(name))) return null;
|
|
877
|
+
const appInfo = systemLstatOrNull(io, path.join(root, 'app'));
|
|
878
|
+
if (!appInfo || appInfo.isSymbolicLink() || !appInfo.isDirectory()) return null;
|
|
879
|
+
let payloadUid = appInfo.uid;
|
|
880
|
+
const configInfo = systemLstatOrNull(io, path.join(root, 'config'));
|
|
881
|
+
if (configInfo) {
|
|
882
|
+
if (configInfo.isSymbolicLink() || !configInfo.isDirectory()) return null;
|
|
883
|
+
payloadUid = configInfo.uid;
|
|
884
|
+
}
|
|
885
|
+
for (const name of names) {
|
|
886
|
+
const info = lstat(path.join(root, name));
|
|
887
|
+
if (info.isSymbolicLink() || (info.uid !== 0 && info.uid !== payloadUid)) return null;
|
|
888
|
+
}
|
|
889
|
+
const helper = systemLstatOrNull(io, INCOMPLETE_HELPER_APP);
|
|
890
|
+
if (helper) {
|
|
891
|
+
if (helper.isSymbolicLink() || !helper.isDirectory() || helper.uid !== 0 || (helper.mode & 0o022)) return null;
|
|
892
|
+
// A bound/prepared helper, external lifecycle custody or the other
|
|
893
|
+
// ordinary root all require their own trusted maintenance.
|
|
894
|
+
if (systemLstatOrNull(io, INCOMPLETE_HELPER_PROFILE) || systemLstatOrNull(io, INCOMPLETE_SYSTEM_LIFECYCLE) ||
|
|
895
|
+
systemLstatOrNull(io, INCOMPLETE_SYSTEM_PAIRED_ROOT)) return null;
|
|
896
|
+
const bootstrap = JSON.parse(readSystemFile(io, INCOMPLETE_HELPER_APP + '/helper-bootstrap.json', { mode: 0o600 }));
|
|
897
|
+
const controlPlaneId = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 &&
|
|
898
|
+
value.trim() === value && !/[\x00-\x1f\x7f]/.test(value);
|
|
899
|
+
const id = (value) => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value);
|
|
900
|
+
if (!bootstrap || typeof bootstrap !== 'object' || Array.isArray(bootstrap) ||
|
|
901
|
+
Object.keys(bootstrap).length !== SYSTEM_BOOTSTRAP_FIELDS.length ||
|
|
902
|
+
!SYSTEM_BOOTSTRAP_FIELDS.every((field) => Object.hasOwn(bootstrap, field)) ||
|
|
903
|
+
!/^nc_[0-9a-f]{64}$/.test(bootstrap.client_id) || !controlPlaneId(bootstrap.control_plane_id) ||
|
|
904
|
+
!id(bootstrap.tenant_id) || !id(bootstrap.helper_key_id) || bootstrap.root_consent !== true ||
|
|
905
|
+
bootstrap.helper_key_binding_id !== null || bootstrap.binding_state !== 'pending' ||
|
|
906
|
+
bootstrap.lifecycle_custody_state !== 'not_initialized' || bootstrap.ordinary_root !== root ||
|
|
907
|
+
bootstrap.ordinary_service_scope !== 'system' ||
|
|
908
|
+
bootstrap.ack_private_key_file !== INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem' ||
|
|
909
|
+
!systemCanonical(bootstrap.node_bin)) return null;
|
|
910
|
+
readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper.json', { mode: 0o600 });
|
|
911
|
+
readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem', { mode: 0o600 });
|
|
912
|
+
readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-public.pem', { mode: 0o644 });
|
|
913
|
+
const peercred = systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred');
|
|
914
|
+
if (!peercred || peercred.isSymbolicLink() || !peercred.isFile() || peercred.uid !== 0 || peercred.nlink !== 1) return null;
|
|
915
|
+
const daemon = systemLstatOrNull(io, INCOMPLETE_HELPER_DAEMON);
|
|
916
|
+
if (!daemon || daemon.isSymbolicLink() || !daemon.isFile() || daemon.uid !== 0 || !(daemon.mode & 0o111)) return null;
|
|
917
|
+
readSystemFile(io, INCOMPLETE_SYSTEM_LAUNCHER, { uids: new Set([0, payloadUid]) });
|
|
918
|
+
const hostEnv = readSystemFile(io, INCOMPLETE_SYSTEM_HOST_ENV, { uids: new Set([0, payloadUid]) });
|
|
919
|
+
const ids = hostEnv.split(/\r?\n/).filter((line) => /^\s*NC_CLIENT_ID\s*=/.test(line));
|
|
920
|
+
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*$/);
|
|
921
|
+
if (ids.length !== 1 || !clientId || (clientId[1] || clientId[2] || clientId[3]) !== bootstrap.client_id) return null;
|
|
922
|
+
systemValidateClientUnit(io, bootstrap.node_bin);
|
|
923
|
+
const helperArtifacts = systemValidateHelperArtifacts(io, bootstrap.node_bin);
|
|
924
|
+
return Object.freeze({ kind: 'pending-helper', runtime: bootstrap.node_bin, payloadUid, helperArtifacts });
|
|
925
|
+
}
|
|
926
|
+
// Failed-install shape: no helper payload, no packaged uninstaller, and an
|
|
927
|
+
// exactly matching stale unit whose launcher never landed (or is empty).
|
|
928
|
+
if (systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred')) return null;
|
|
929
|
+
if (systemLstatOrNull(io, path.join(root, 'uninstall.sh'))) return null;
|
|
930
|
+
for (const artifact of [INCOMPLETE_HELPER_UNIT, INCOMPLETE_HELPER_SOCKET, INCOMPLETE_HELPER_TMPFILES])
|
|
931
|
+
if (systemLstatOrNull(io, artifact)) return null;
|
|
932
|
+
if (systemLstatOrNull(io, INCOMPLETE_SYSTEM_LIFECYCLE) || systemLstatOrNull(io, INCOMPLETE_SYSTEM_PAIRED_ROOT)) return null;
|
|
933
|
+
const libexec = systemLstatOrNull(io, path.join(root, 'libexec'));
|
|
934
|
+
if (libexec) {
|
|
935
|
+
if (libexec.isSymbolicLink() || !libexec.isDirectory() || !systemValidateStagedHelperBootstrap(io, path.join(root, 'libexec'))) return null;
|
|
936
|
+
}
|
|
937
|
+
const launcher = systemLstatOrNull(io, INCOMPLETE_SYSTEM_LAUNCHER);
|
|
938
|
+
if (launcher && (launcher.isSymbolicLink() || !launcher.isFile() || launcher.size !== 0 ||
|
|
939
|
+
(launcher.uid !== 0 && launcher.uid !== payloadUid))) return null;
|
|
940
|
+
systemValidateClientUnit(io, INCOMPLETE_SYSTEM_NODE);
|
|
941
|
+
return Object.freeze({
|
|
942
|
+
kind: 'failed-install',
|
|
943
|
+
runtime: INCOMPLETE_SYSTEM_NODE,
|
|
944
|
+
payloadUid,
|
|
945
|
+
helperArtifacts: Object.freeze({ service: false, tmpfiles: false })
|
|
946
|
+
});
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Root-only removal of the exact incomplete system shapes above. Stops and
|
|
954
|
+
* disables the selected client, removes only the exact helper artifacts that
|
|
955
|
+
* point at /opt/nc, then removes the bounded tree. The dedicated nodecommand
|
|
956
|
+
* account is never touched, and no other path is eligible.
|
|
957
|
+
* @param {string} root
|
|
958
|
+
* @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs, run?: Function }} [options]
|
|
959
|
+
* @returns {'pending-helper'|'failed-install'}
|
|
960
|
+
*/
|
|
961
|
+
export function removeIncompleteSystemInstallation(root, {
|
|
962
|
+
platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid, io = fs, run = child_process.spawnSync
|
|
963
|
+
} = {}) {
|
|
964
|
+
const refused = (reason) => Object.assign(
|
|
965
|
+
new Error(`Refusing incomplete system removal of ${root}: ${reason}.`),
|
|
966
|
+
{ code: 'PARTIAL_SYSTEM_REMOVAL_REFUSED' }
|
|
967
|
+
);
|
|
968
|
+
if (!isPartialSystemRemovalCandidate(root, { platform, getuid, geteuid })) {
|
|
969
|
+
throw refused('it is not the canonical incomplete system root');
|
|
970
|
+
}
|
|
971
|
+
const shape = incompleteSystemInstallationShape(root, { platform, io });
|
|
972
|
+
if (!shape) throw refused('the system tree is not the exact pending-helper or failed-install shape');
|
|
973
|
+
const unlink = io.unlinkSync || fs.unlinkSync;
|
|
974
|
+
const systemctl = (args) => {
|
|
975
|
+
const result = run('/usr/bin/systemctl', ['--system', '--no-pager', '--no-ask-password', ...args], {
|
|
976
|
+
env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C', LC_ALL: 'C' }, cwd: '/', encoding: 'utf8',
|
|
977
|
+
timeout: 15000, stdio: ['ignore', 'pipe', 'pipe']
|
|
978
|
+
});
|
|
979
|
+
if (!result || result.error || result.signal || result.status !== 0) throw refused(`systemctl ${args.join(' ')} failed`);
|
|
980
|
+
return String(result.stdout ?? '');
|
|
981
|
+
};
|
|
982
|
+
try {
|
|
983
|
+
systemctl(['disable', '--now', 'nc-client.service']);
|
|
984
|
+
if (shape.kind === 'pending-helper' && shape.helperArtifacts.service) {
|
|
985
|
+
systemctl(['disable', '--now', 'nc-privileged-helper.socket']);
|
|
986
|
+
systemctl(['stop', 'nc-privileged-helper.service']);
|
|
987
|
+
try {
|
|
988
|
+
systemLstat(io)(INCOMPLETE_HELPER_SOCKET_PATH);
|
|
989
|
+
throw refused('the privileged helper socket is still present after stop');
|
|
990
|
+
} catch (error) {
|
|
991
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
992
|
+
}
|
|
993
|
+
if (shape.helperArtifacts.tmpfiles) unlink(INCOMPLETE_HELPER_TMPFILES);
|
|
994
|
+
unlink(INCOMPLETE_HELPER_SOCKET);
|
|
995
|
+
unlink(INCOMPLETE_HELPER_UNIT);
|
|
996
|
+
}
|
|
997
|
+
unlink(INCOMPLETE_SYSTEM_UNIT);
|
|
998
|
+
systemctl(['daemon-reload']);
|
|
999
|
+
const units = shape.kind === 'pending-helper' && shape.helperArtifacts.service
|
|
1000
|
+
? ['nc-client.service', 'nc-privileged-helper.socket', 'nc-privileged-helper.service']
|
|
1001
|
+
: ['nc-client.service'];
|
|
1002
|
+
for (const unit of units) {
|
|
1003
|
+
const state = systemctl(['show', unit, '--property=LoadState', '--value']).trim();
|
|
1004
|
+
if (state !== 'not-found') throw refused(`${unit} is still loaded as ${state}`);
|
|
1005
|
+
}
|
|
1006
|
+
removeBoundedRoot(root, {
|
|
1007
|
+
uid: 0,
|
|
1008
|
+
allowedUids: new Set([0, shape.payloadUid]),
|
|
1009
|
+
allowedEntries: INCOMPLETE_SYSTEM_ENTRIES,
|
|
1010
|
+
io,
|
|
1011
|
+
refused,
|
|
1012
|
+
owner: 'root',
|
|
1013
|
+
verifyConfig: () => {}
|
|
1014
|
+
});
|
|
1015
|
+
return shape.kind;
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
if (error?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw error;
|
|
1018
|
+
throw refused(error instanceof Error ? error.message : 'the tree could not be verified');
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
699
1022
|
/**
|
|
700
1023
|
* Canonical machine-wide roots that exist but cannot be inspected by the
|
|
701
1024
|
* current unprivileged caller. Setup must escalate before deciding anything
|
|
@@ -773,16 +1096,21 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
|
773
1096
|
const install = [];
|
|
774
1097
|
const unreadable = [];
|
|
775
1098
|
let pair = null;
|
|
1099
|
+
let incompleteRoot = null;
|
|
776
1100
|
if ((rest.platform || os.platform()) === 'linux') {
|
|
1101
|
+
let pairedError = null;
|
|
777
1102
|
try {
|
|
778
1103
|
pair = linuxPairedInstallation(io);
|
|
779
1104
|
} catch (err) {
|
|
780
1105
|
// Protected metadata this account cannot stat or read is not a local
|
|
781
|
-
// removal target.
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1106
|
+
// removal target. Any other validation failure remains a hard stop
|
|
1107
|
+
// unless the tree is the exact recoverable pending/failed shape.
|
|
1108
|
+
if (err?.code !== 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw err;
|
|
1109
|
+
pairedError = err;
|
|
1110
|
+
}
|
|
1111
|
+
if (!pair) {
|
|
1112
|
+
if (incompleteSystemInstallationShape(PARTIAL_SYSTEM_ROOT, { platform: 'linux', io })) incompleteRoot = PARTIAL_SYSTEM_ROOT;
|
|
1113
|
+
if (pairedError && !incompleteRoot && !pairedMetadataUnreadable(io)) throw pairedError;
|
|
786
1114
|
}
|
|
787
1115
|
}
|
|
788
1116
|
const env = rest.env || process.env;
|
|
@@ -796,6 +1124,13 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
|
796
1124
|
else if (status === 'foreign' && !install.includes(root) && isPartialSystemRemovalCandidate(root, rest) &&
|
|
797
1125
|
partialEnrollmentSystemRoot(root, io)) install.push(root);
|
|
798
1126
|
}
|
|
1127
|
+
// The exact pending-helper / failed-install system tree is a bounded target
|
|
1128
|
+
// only for a real+effective root caller; an ordinary caller gets the
|
|
1129
|
+
// trusted-maintenance message instead of a silent "nothing to remove".
|
|
1130
|
+
if (incompleteRoot && !install.includes(incompleteRoot) && !unreadable.includes(incompleteRoot)) {
|
|
1131
|
+
if (isPartialSystemRemovalCandidate(incompleteRoot, rest)) install.push(incompleteRoot);
|
|
1132
|
+
else unreadable.push(incompleteRoot);
|
|
1133
|
+
}
|
|
799
1134
|
// The paired relation is only actionable when its root is itself readable.
|
|
800
1135
|
// An unverifiable/foreign paired root is reported through `unreadable`.
|
|
801
1136
|
const paired = pair && install.includes(pair.ncRoot) ? [pair] : [];
|