node-command 2.0.8 → 2.0.10
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 +194 -20
- package/lib/setup.mjs +111 -23
- package/package.json +10 -5
package/bin/nc.mjs
CHANGED
|
@@ -21,9 +21,11 @@ import {
|
|
|
21
21
|
getNcRootForMode,
|
|
22
22
|
installerEnvForMode,
|
|
23
23
|
isElevated,
|
|
24
|
+
isPartialSystemRemovalCandidate,
|
|
24
25
|
isPartialUserRemovalCandidate,
|
|
25
26
|
isUserInstallRoot,
|
|
26
27
|
probeInstallRoot,
|
|
28
|
+
removePartialSystemRoot,
|
|
27
29
|
removePartialUserRoot,
|
|
28
30
|
resolveExecutable,
|
|
29
31
|
setupTarget,
|
|
@@ -1527,6 +1529,75 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
|
|
|
1527
1529
|
}
|
|
1528
1530
|
}
|
|
1529
1531
|
|
|
1532
|
+
/**
|
|
1533
|
+
* Fetches the device-authenticated install context for the protected helper.
|
|
1534
|
+
* The control plane supplies only client/tenant/control-plane identity; the
|
|
1535
|
+
* trust anchor (pin + digest) is the OTA-signed runtime release. The response
|
|
1536
|
+
* must be exactly {tenant_id, control_plane_id}: any pin or extra field is
|
|
1537
|
+
* rejected. A missing/incomplete response is a non-fatal warning: ordinary
|
|
1538
|
+
* enrollment continues without the helper.
|
|
1539
|
+
*/
|
|
1540
|
+
async function fetchPrivilegedInstallContext({ baseUrl, hostId, hostToken, privateKey }) {
|
|
1541
|
+
const contextUrl = `${baseUrl}/api/clients/${encodeURIComponent(hostId)}/privileged-helper/context`;
|
|
1542
|
+
try {
|
|
1543
|
+
assertCredentialOrigin(contextUrl, { label: 'helper context URL', action: 'fetch' });
|
|
1544
|
+
const data = await fetchJson(contextUrl, buildDeviceDownloadHeaders({ hostId, hostToken, privateKey }, contextUrl));
|
|
1545
|
+
const fields = ['tenant_id', 'control_plane_id'];
|
|
1546
|
+
const tenantId = typeof data?.tenant_id === 'string' ? data.tenant_id.trim() : '';
|
|
1547
|
+
const controlPlaneId = typeof data?.control_plane_id === 'string' ? data.control_plane_id.trim() : '';
|
|
1548
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) ||
|
|
1549
|
+
Object.keys(data).length !== fields.length || !fields.every((field) => Object.hasOwn(data, field)) ||
|
|
1550
|
+
!tenantId || !controlPlaneId || tenantId.length > 256 || controlPlaneId.length > 256) {
|
|
1551
|
+
console.warn('Warning: the control plane did not return the identity-only helper install context (tenant id, control-plane id) and no pin is accepted from device auth. Continuing ordinary enrollment without the privileged helper; re-run setup after the control plane advertises the helper context endpoint.');
|
|
1552
|
+
return null;
|
|
1553
|
+
}
|
|
1554
|
+
return { client_id: hostId, tenant_id: tenantId, control_plane_id: controlPlaneId };
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
const detail = sanitizeForDisplay(err instanceof Error ? err.message : String(err), 200);
|
|
1557
|
+
console.warn(`Warning: could not obtain the helper install context (${detail}). Continuing ordinary enrollment without the privileged helper; re-run setup with root consent after the control plane adds the /privileged-helper/context endpoint.`);
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Publishes the locally generated helper public key as a pending candidate.
|
|
1564
|
+
* Only the public key is sent; a failure never rolls back the installed agent.
|
|
1565
|
+
*/
|
|
1566
|
+
async function publishPrivilegedHelperCandidate({ root, baseUrl, hostId, hostToken, privateKey }) {
|
|
1567
|
+
const publicKeyPath = path.join(root, 'libexec', 'privileged-helper', 'privileged-helper-public.pem');
|
|
1568
|
+
let pem;
|
|
1569
|
+
try {
|
|
1570
|
+
const info = fs.lstatSync(publicKeyPath);
|
|
1571
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('not a regular file');
|
|
1572
|
+
pem = fs.readFileSync(publicKeyPath, 'utf8');
|
|
1573
|
+
} catch {
|
|
1574
|
+
return { published: false, warning: 'Admin remains pending; candidate publication failed: protected helper public key not found.' };
|
|
1575
|
+
}
|
|
1576
|
+
try {
|
|
1577
|
+
const publicKey = crypto.createPublicKey(pem);
|
|
1578
|
+
if (publicKey.asymmetricKeyType !== 'ed25519') throw new Error('helper public key is not Ed25519');
|
|
1579
|
+
const candidateUrl = `${baseUrl}/api/clients/${encodeURIComponent(hostId)}/privileged-helper/candidate`;
|
|
1580
|
+
assertCredentialOrigin(candidateUrl, { label: 'helper candidate URL', action: 'publish' });
|
|
1581
|
+
const proof = createDeviceProofCli({ privateKey, hostId, method: 'POST', url: candidateUrl, credential: hostToken });
|
|
1582
|
+
const res = await postJson(
|
|
1583
|
+
candidateUrl,
|
|
1584
|
+
{ public_key: pem },
|
|
1585
|
+
{ Authorization: `NC-Device ${hostToken}`, 'NC-Device-Proof': proof }
|
|
1586
|
+
);
|
|
1587
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1588
|
+
throw new Error(sanitizeForDisplay(res.data?.error || `HTTP ${res.status}`, 120));
|
|
1589
|
+
}
|
|
1590
|
+
const keyId = crypto.createHash('sha256').update(publicKey.export({ type: 'spki', format: 'der' })).digest('hex');
|
|
1591
|
+
return { published: true, fingerprint: `sha256:${keyId}` };
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
const detail = sanitizeForDisplay(err instanceof Error ? err.message : String(err), 200);
|
|
1594
|
+
return {
|
|
1595
|
+
published: false,
|
|
1596
|
+
warning: `Admin remains pending; candidate publication failed: ${detail}. The agent is installed without the Admin helper; approve the helper candidate from the dashboard once the control plane accepts it.`
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1530
1601
|
/**
|
|
1531
1602
|
* Downloads, verifies, extracts, and installs the agent package. Writes NO
|
|
1532
1603
|
* credentials — the env file must already exist before this runs.
|
|
@@ -1552,13 +1623,15 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
|
|
|
1552
1623
|
* (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
|
|
1553
1624
|
* proof binds method GET + the full request URL + the redemption-issued
|
|
1554
1625
|
* client token, exactly like redeemEnrollment.
|
|
1555
|
-
* @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', rootConsent?: boolean, runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject, downloadTimeoutMs?: number }} options
|
|
1626
|
+
* @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', rootConsent?: boolean, runInstaller?: (scriptPath: string, ncRoot: string, env?: Record<string, string>) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject, downloadTimeoutMs?: number }} options
|
|
1627
|
+
* @returns {Promise<{ helperCandidate: { published: boolean, fingerprint?: string, warning?: string }|null }>} `helperCandidate` is null when no helper was requested.
|
|
1556
1628
|
*/
|
|
1557
1629
|
export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, rootConsent = false, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined, downloadTimeoutMs = undefined } = {}) {
|
|
1558
1630
|
ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
|
|
1559
1631
|
const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1632
|
+
const helperRequested = rootConsent === true && installMode !== 'user' && os.platform() === 'linux';
|
|
1633
|
+
const invokeInstaller = (scriptPath, installRoot) =>
|
|
1634
|
+
runInstaller ? runInstaller(scriptPath, installRoot, modeEnv) : runInstallerScript(scriptPath, installRoot, modeEnv);
|
|
1562
1635
|
const root = ncRoot;
|
|
1563
1636
|
console.log(`📍 Installation root: ${root}`);
|
|
1564
1637
|
|
|
@@ -1769,6 +1842,68 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1769
1842
|
throw new PairingError('INSTALL_SCRIPT_MISSING', `Installation script not found: ${scriptPath}.`);
|
|
1770
1843
|
}
|
|
1771
1844
|
|
|
1845
|
+
if (helperRequested) {
|
|
1846
|
+
const runtimePackageUrl = (() => {
|
|
1847
|
+
const raw = usableUrl(activeRelease.package_url) || usableUrl(activeRelease.download_url);
|
|
1848
|
+
if (!raw) return null;
|
|
1849
|
+
try {
|
|
1850
|
+
return new URL(raw, defaultBaseUrl).toString();
|
|
1851
|
+
} catch {
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
})();
|
|
1855
|
+
const runtimeSha = typeof activeRelease.sha256 === 'string' ? activeRelease.sha256.toLowerCase() : '';
|
|
1856
|
+
const runtimeSize = activeRelease.size_bytes;
|
|
1857
|
+
const runtimeSignature = typeof activeRelease.signature === 'string' ? activeRelease.signature.trim() : '';
|
|
1858
|
+
const runtimeVersion = typeof version === 'string' ? version : '';
|
|
1859
|
+
const helperPrereqsReady =
|
|
1860
|
+
hasDeviceIdentity &&
|
|
1861
|
+
runtimePackageUrl &&
|
|
1862
|
+
/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(runtimeVersion) &&
|
|
1863
|
+
/^[a-f0-9]{64}$/.test(runtimeSha) &&
|
|
1864
|
+
Number.isSafeInteger(runtimeSize) && runtimeSize > 0 &&
|
|
1865
|
+
/^[A-Za-z0-9_-]{86}$/.test(runtimeSignature);
|
|
1866
|
+
if (!helperPrereqsReady) {
|
|
1867
|
+
console.warn('Warning: the release does not carry a complete signed runtime package (version, package URL, sha256, size, signature) or this setup has no device identity. Continuing ordinary User enrollment without the privileged helper.');
|
|
1868
|
+
} else {
|
|
1869
|
+
const context = await fetchPrivilegedInstallContext({ baseUrl: defaultBaseUrl, hostId, hostToken, privateKey });
|
|
1870
|
+
if (context) {
|
|
1871
|
+
// Fetch the runtime archive with the device credential here; the root
|
|
1872
|
+
// installer must never need a client token and cannot fetch the
|
|
1873
|
+
// authenticated package endpoint itself.
|
|
1874
|
+
const runtimeArchivePath = path.join(tempDir, 'client.tar.gz');
|
|
1875
|
+
try {
|
|
1876
|
+
assertCredentialOrigin(runtimePackageUrl, { label: 'runtime release URL', action: 'fetch' });
|
|
1877
|
+
if (!isSameOrigin(runtimePackageUrl, defaultBaseUrl)) {
|
|
1878
|
+
throw new Error('runtime release URL is not on the trusted enrollment origin');
|
|
1879
|
+
}
|
|
1880
|
+
const download = await downloadToFile(
|
|
1881
|
+
runtimePackageUrl,
|
|
1882
|
+
runtimeArchivePath,
|
|
1883
|
+
buildDeviceDownloadHeaders({ hostId, hostToken, privateKey }, runtimePackageUrl),
|
|
1884
|
+
{ maxBytes: getCliMaxPackageBytes(), timeoutMs: downloadTimeoutMs ?? getCliDownloadTimeoutMs() }
|
|
1885
|
+
);
|
|
1886
|
+
if (download.bytes !== runtimeSize || download.sha256.toLowerCase() !== runtimeSha) {
|
|
1887
|
+
throw new Error('runtime release archive does not match the signed digest/size');
|
|
1888
|
+
}
|
|
1889
|
+
modeEnv.NC_PRIVILEGED_INSTALL_CONTEXT = JSON.stringify(context);
|
|
1890
|
+
modeEnv.NC_PRIVILEGED_ARCHIVE_FILE = runtimeArchivePath;
|
|
1891
|
+
modeEnv.NC_PRIVILEGED_RELEASE_VERSION = runtimeVersion;
|
|
1892
|
+
modeEnv.NC_PRIVILEGED_RELEASE_URL = runtimePackageUrl;
|
|
1893
|
+
modeEnv.NC_PRIVILEGED_RELEASE_SHA256 = runtimeSha;
|
|
1894
|
+
modeEnv.NC_PRIVILEGED_RELEASE_SIZE = String(runtimeSize);
|
|
1895
|
+
modeEnv.NC_PRIVILEGED_RELEASE_SIGNATURE = runtimeSignature;
|
|
1896
|
+
// The installer compares the archive URL origin with the enrollment
|
|
1897
|
+
// server origin. modeEnv values win over inherited NC_* variables.
|
|
1898
|
+
modeEnv.NC_SERVER_URL = defaultBaseUrl;
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
const detail = sanitizeForDisplay(err instanceof Error ? err.message : String(err), 200);
|
|
1901
|
+
console.warn(`Warning: could not prepare the signed runtime release for the privileged helper (${detail}). Continuing ordinary User enrollment without the privileged helper.`);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1772
1907
|
try {
|
|
1773
1908
|
invokeInstaller(scriptPath, root);
|
|
1774
1909
|
} catch (err) {
|
|
@@ -1783,6 +1918,21 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1783
1918
|
console.log('🧹 Cleaning up temporary directory...');
|
|
1784
1919
|
cleanup();
|
|
1785
1920
|
}
|
|
1921
|
+
|
|
1922
|
+
let helperCandidate = null;
|
|
1923
|
+
if (helperRequested && hasDeviceIdentity) {
|
|
1924
|
+
helperCandidate = await publishPrivilegedHelperCandidate({
|
|
1925
|
+
root,
|
|
1926
|
+
baseUrl: defaultBaseUrl,
|
|
1927
|
+
hostId,
|
|
1928
|
+
hostToken,
|
|
1929
|
+
privateKey
|
|
1930
|
+
});
|
|
1931
|
+
if (helperCandidate.published === false && helperCandidate.warning) {
|
|
1932
|
+
console.warn(`Warning: ${helperCandidate.warning}`);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return { helperCandidate };
|
|
1786
1936
|
}
|
|
1787
1937
|
|
|
1788
1938
|
/**
|
|
@@ -1837,7 +1987,7 @@ export async function pairDeviceAndInstall({
|
|
|
1837
1987
|
const installAgent =
|
|
1838
1988
|
hooks.installAgent ||
|
|
1839
1989
|
(async ({ ncRoot: installRoot, serverUrl: installServerUrl, release: installRelease = undefined, hostId: installHostId = undefined, hostToken: installHostToken = undefined, privateKey: installPrivateKey = undefined }) => {
|
|
1840
|
-
await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, rootConsent: selectedRootConsent, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
|
|
1990
|
+
return await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, rootConsent: selectedRootConsent, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
|
|
1841
1991
|
});
|
|
1842
1992
|
let selectedRootConsent = false;
|
|
1843
1993
|
|
|
@@ -1874,7 +2024,7 @@ export async function pairDeviceAndInstall({
|
|
|
1874
2024
|
return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
|
|
1875
2025
|
}
|
|
1876
2026
|
console.log(`🔁 Resuming installation for host ${localEnv.NC_CLIENT_ID} (stored credentials verified)...`);
|
|
1877
|
-
await installAgent({ ncRoot: root, serverUrl: storedServerUrl, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
|
|
2027
|
+
const installOutcome = await installAgent({ ncRoot: root, serverUrl: storedServerUrl, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
|
|
1878
2028
|
writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
|
|
1879
2029
|
await verifyHostRegistration({
|
|
1880
2030
|
apiBase: localEnv.NC_SERVER_URL,
|
|
@@ -1885,7 +2035,8 @@ export async function pairDeviceAndInstall({
|
|
|
1885
2035
|
intervalMs: verifyIntervalMs
|
|
1886
2036
|
});
|
|
1887
2037
|
writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
|
|
1888
|
-
return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true
|
|
2038
|
+
return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true,
|
|
2039
|
+
helperCandidate: installOutcome?.helperCandidate ?? null };
|
|
1889
2040
|
} catch (err) {
|
|
1890
2041
|
// Failure at the bound destination is not permission to enroll on
|
|
1891
2042
|
// another server or replace this installed host identity.
|
|
@@ -1954,7 +2105,7 @@ export async function pairDeviceAndInstall({
|
|
|
1954
2105
|
writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
|
|
1955
2106
|
|
|
1956
2107
|
console.log('🚀 Installing agent...');
|
|
1957
|
-
await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
|
|
2108
|
+
const installOutcome = await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
|
|
1958
2109
|
writePairState(statePath, { host_id: hostId, phase: 'installed' });
|
|
1959
2110
|
|
|
1960
2111
|
console.log(`🔍 Verifying host registration at ${sanitizeForDisplay(enrollServerUrl, 256)}...`);
|
|
@@ -1971,7 +2122,8 @@ export async function pairDeviceAndInstall({
|
|
|
1971
2122
|
});
|
|
1972
2123
|
writePairState(statePath, { host_id: hostId, phase: 'verified' });
|
|
1973
2124
|
return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion, rootConsent: selectedRootConsent,
|
|
1974
|
-
privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization
|
|
2125
|
+
privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization,
|
|
2126
|
+
helperCandidate: installOutcome?.helperCandidate ?? null };
|
|
1975
2127
|
}
|
|
1976
2128
|
|
|
1977
2129
|
/**
|
|
@@ -2233,9 +2385,9 @@ function getCliVersion() {
|
|
|
2233
2385
|
/**
|
|
2234
2386
|
* Prints the one-line install summary required after a successful setup:
|
|
2235
2387
|
* host id, install mode, CLI version, and verification status.
|
|
2236
|
-
* @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any }} options
|
|
2388
|
+
* @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any, helperCandidate?: { published: boolean, fingerprint?: string, warning?: string }|null }} options
|
|
2237
2389
|
*/
|
|
2238
|
-
export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null }) {
|
|
2390
|
+
export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null, helperCandidate = null }) {
|
|
2239
2391
|
console.log(`\n✅ Setup complete for host ${sanitizeForDisplay(hostId, 128)}${resumed ? ' (resumed)' : ''}.`);
|
|
2240
2392
|
if (installMode) console.log(`installed as ${installMode}`);
|
|
2241
2393
|
if (version) console.log(`agent version: ${sanitizeForDisplay(version, 120)}`);
|
|
@@ -2249,7 +2401,17 @@ export function printSetupSuccess({ hostId, serverUrl, installMode = undefined,
|
|
|
2249
2401
|
console.log('execution permission: existing choice preserved; no privilege maintenance performed');
|
|
2250
2402
|
} else {
|
|
2251
2403
|
console.log(`root command consent: ${rootConsent === true ? 'Yes' : 'No'}`);
|
|
2252
|
-
|
|
2404
|
+
if (rootConsent !== true) {
|
|
2405
|
+
console.log('local privilege readiness: unavailable (not requested)');
|
|
2406
|
+
} else if (helperCandidate && helperCandidate.published === true) {
|
|
2407
|
+
const match = /^sha256:([0-9a-f]{64})$/i.exec(String(helperCandidate.fingerprint || ''));
|
|
2408
|
+
const shortFingerprint = match ? `sha256:${match[1].slice(0, 16).toLowerCase()}` : null;
|
|
2409
|
+
console.log(`local privilege readiness: Admin pending: approve the helper candidate in the dashboard${shortFingerprint ? ` (fingerprint: ${shortFingerprint})` : ''}`);
|
|
2410
|
+
} else if (helperCandidate && typeof helperCandidate.warning === 'string' && helperCandidate.warning) {
|
|
2411
|
+
console.log(`local privilege readiness: ${helperCandidate.warning}`);
|
|
2412
|
+
} else {
|
|
2413
|
+
console.log('local privilege readiness: pending independent management approval and protected helper binding');
|
|
2414
|
+
}
|
|
2253
2415
|
const receipt = executionInitialization;
|
|
2254
2416
|
const fields = ['outcome', 'reason', 'subject_user_id', 'permission'];
|
|
2255
2417
|
const reasons = { requested: ['admin_requested'], preserved: ['existing_permission_preserved', 'existing_host_preserved'],
|
|
@@ -2960,11 +3122,17 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
2960
3122
|
// A failed per-user install can leave its own tree (config plus partial
|
|
2961
3123
|
// payload) without the packaged uninstaller. Only the canonical user root
|
|
2962
3124
|
// is eligible, and the bounded removal revalidates ancestry, ownership and
|
|
2963
|
-
// every top-level entry.
|
|
3125
|
+
// every top-level entry. Real system installs never use this fallback;
|
|
3126
|
+
// the exact /opt/nc partial tree has its own narrower root-only exception.
|
|
2964
3127
|
const scriptExists = pair || fs.existsSync(script);
|
|
2965
|
-
const
|
|
2966
|
-
|
|
2967
|
-
|
|
3128
|
+
const explicitRemoval = command === 'uninstall' || command === 'remove';
|
|
3129
|
+
const partialUserTree = !scriptExists && explicitRemoval && isPartialUserRemovalCandidate(root);
|
|
3130
|
+
// The narrow system exception is /opt/nc only, requires real+effective
|
|
3131
|
+
// root, and refuses app/, uninstall.sh, units, libexec/ and lifecycle
|
|
3132
|
+
// metadata inside removePartialSystemRoot.
|
|
3133
|
+
const partialSystemTree = !scriptExists && explicitRemoval && isPartialSystemRemovalCandidate(root);
|
|
3134
|
+
const boundedTree = partialUserTree || partialSystemTree;
|
|
3135
|
+
if (!scriptExists && !boundedTree) {
|
|
2968
3136
|
console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
|
|
2969
3137
|
console.error(uninstallFailureSuggestion());
|
|
2970
3138
|
failed += 1;
|
|
@@ -2979,13 +3147,19 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
2979
3147
|
continue;
|
|
2980
3148
|
}
|
|
2981
3149
|
try {
|
|
2982
|
-
if (
|
|
3150
|
+
if (boundedTree) {
|
|
2983
3151
|
try {
|
|
2984
|
-
|
|
2985
|
-
|
|
3152
|
+
if (partialSystemTree) removePartialSystemRoot(root);
|
|
3153
|
+
else removePartialUserRoot(root);
|
|
3154
|
+
console.log(partialSystemTree
|
|
3155
|
+
? `Removed partial system Node Command installation at ${root} (no packaged uninstaller, unit or application payload).`
|
|
3156
|
+
: `Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
|
|
2986
3157
|
} catch (err) {
|
|
2987
|
-
|
|
2988
|
-
console.error(
|
|
3158
|
+
const code = typeof err?.code === 'string' && /_REFUSED$/.test(err.code) ? err.code : 'PARTIAL_ROOT_REMOVAL_REFUSED';
|
|
3159
|
+
console.error(`error: [${code}] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
|
|
3160
|
+
console.error(partialSystemTree
|
|
3161
|
+
? '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
|
+
: '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.');
|
|
2989
3163
|
failed += 1;
|
|
2990
3164
|
}
|
|
2991
3165
|
continue;
|
package/lib/setup.mjs
CHANGED
|
@@ -450,6 +450,26 @@ const USER_INSTALL_ENTRIES = new Set([
|
|
|
450
450
|
'uninstall.sh'
|
|
451
451
|
]);
|
|
452
452
|
|
|
453
|
+
/** Canonical system root eligible only for a narrow, root-only cleanup. */
|
|
454
|
+
export const PARTIAL_SYSTEM_ROOT = '/opt/nc';
|
|
455
|
+
|
|
456
|
+
/** A failed system install may leave only these payload/config directories. */
|
|
457
|
+
const PARTIAL_SYSTEM_ENTRIES = new Set(['config', 'tools', 'logs', 'tmp', 'releases', 'backups']);
|
|
458
|
+
|
|
459
|
+
/** Artifacts that prove a real system installation exists; never bypassed. */
|
|
460
|
+
const PARTIAL_SYSTEM_FORBIDDEN_PATHS = [
|
|
461
|
+
'/etc/systemd/system/nc-client.service',
|
|
462
|
+
'/etc/systemd/system/nc-client.service.d',
|
|
463
|
+
'/etc/systemd/user/nc-client.service',
|
|
464
|
+
'/etc/systemd/user/nc-client.service.d',
|
|
465
|
+
'/etc/systemd/system/multi-user.target.wants/nc-client.service',
|
|
466
|
+
'/etc/systemd/system/nc-privileged-helper.service',
|
|
467
|
+
'/etc/systemd/system/nc-privileged-helper.socket',
|
|
468
|
+
'/etc/tmpfiles.d/nc-privileged-helper.conf',
|
|
469
|
+
'/var/lib/nc-lifecycle',
|
|
470
|
+
'/var/lib/nodecommand/nc'
|
|
471
|
+
];
|
|
472
|
+
|
|
453
473
|
/**
|
|
454
474
|
* True when `root` is the canonical per-user tree for the current account and
|
|
455
475
|
* is not a protected system root. This is the cheap path/ownership gate; the
|
|
@@ -475,6 +495,52 @@ export function isPartialUserRemovalCandidate(root, { platform = os.platform(),
|
|
|
475
495
|
);
|
|
476
496
|
}
|
|
477
497
|
|
|
498
|
+
/**
|
|
499
|
+
* True only for the exact Linux system root when the process holds real AND
|
|
500
|
+
* effective root. The tree contents and every real-install artifact are
|
|
501
|
+
* checked again inside `removePartialSystemRoot`.
|
|
502
|
+
* @param {string} root
|
|
503
|
+
* @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null }} [options]
|
|
504
|
+
*/
|
|
505
|
+
export function isPartialSystemRemovalCandidate(root, { platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid } = {}) {
|
|
506
|
+
if (platform !== 'linux') return false;
|
|
507
|
+
if (typeof getuid !== 'function' || typeof geteuid !== 'function') return false;
|
|
508
|
+
try {
|
|
509
|
+
if (getuid() !== 0 || geteuid() !== 0) return false;
|
|
510
|
+
} catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
return path.resolve(root) === PARTIAL_SYSTEM_ROOT;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Shared bounded removal of an already-authorized root. */
|
|
517
|
+
function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
|
|
518
|
+
const resolved = path.resolve(root);
|
|
519
|
+
if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
|
|
520
|
+
const rootInfo = io.lstatSync(resolved);
|
|
521
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw refused('the root is not a regular directory');
|
|
522
|
+
if (rootInfo.uid !== uid) throw refused(`the root is not owned by ${owner}`);
|
|
523
|
+
const entries = io.readdirSync(resolved);
|
|
524
|
+
for (const name of entries) {
|
|
525
|
+
if (!allowedEntries.has(name)) throw refused(`unexpected top-level entry "${name}"`);
|
|
526
|
+
const info = io.lstatSync(path.join(resolved, name));
|
|
527
|
+
if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
|
|
528
|
+
if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
|
|
529
|
+
}
|
|
530
|
+
for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
|
|
531
|
+
const info = io.lstatSync(path.join(resolved, file));
|
|
532
|
+
if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
|
|
533
|
+
throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
|
|
534
|
+
}
|
|
535
|
+
if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
|
|
536
|
+
}
|
|
537
|
+
io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
|
|
538
|
+
for (const name of entries) {
|
|
539
|
+
io.rmSync(path.join(resolved, name), { recursive: true, force: true });
|
|
540
|
+
}
|
|
541
|
+
io.rmdirSync(resolved);
|
|
542
|
+
}
|
|
543
|
+
|
|
478
544
|
/**
|
|
479
545
|
* Bounded removal of a per-user Node Command tree whose packaged `uninstall.sh`
|
|
480
546
|
* is missing (a partial/failed install). Only the canonical per-user root is
|
|
@@ -494,33 +560,55 @@ export function removePartialUserRoot(root, { platform = os.platform(), env = pr
|
|
|
494
560
|
if (!isPartialUserRemovalCandidate(root, { platform, env, homedir, uid })) {
|
|
495
561
|
throw refused('it is not the canonical per-user Node Command root');
|
|
496
562
|
}
|
|
497
|
-
const resolved = path.resolve(root);
|
|
498
563
|
try {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
564
|
+
removeBoundedRoot(root, {
|
|
565
|
+
uid,
|
|
566
|
+
allowedEntries: USER_INSTALL_ENTRIES,
|
|
567
|
+
io,
|
|
568
|
+
refused,
|
|
569
|
+
owner: 'the current user'
|
|
570
|
+
});
|
|
571
|
+
} catch (err) {
|
|
572
|
+
if (err?.code === 'USER_TREE_REMOVAL_REFUSED') throw err;
|
|
573
|
+
throw refused(err instanceof Error ? err.message : 'the tree could not be verified');
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Root-only bounded removal of a failed SYSTEM install at /opt/nc. This is not
|
|
579
|
+
* a general system-root fallback: it requires real+effective root, the exact
|
|
580
|
+
* canonical root, no unit/helper/lifecycle artifact, and only the known partial
|
|
581
|
+
* payload directories. A real or unverifiable installation stays fail-closed.
|
|
582
|
+
* @param {string} root
|
|
583
|
+
* @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs }} [options]
|
|
584
|
+
*/
|
|
585
|
+
export function removePartialSystemRoot(root, { platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid, io = fs } = {}) {
|
|
586
|
+
const refused = (reason) => Object.assign(
|
|
587
|
+
new Error(`Refusing bounded removal of ${root}: ${reason}.`),
|
|
588
|
+
{ code: 'PARTIAL_SYSTEM_REMOVAL_REFUSED' }
|
|
589
|
+
);
|
|
590
|
+
if (!isPartialSystemRemovalCandidate(root, { platform, getuid, geteuid })) {
|
|
591
|
+
throw refused('it is not the canonical partial system root');
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
for (const file of PARTIAL_SYSTEM_FORBIDDEN_PATHS) {
|
|
595
|
+
try {
|
|
596
|
+
io.lstatSync(file);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
if (err?.code === 'ENOENT') continue;
|
|
599
|
+
throw refused(`existing installation state at ${file} cannot be verified`);
|
|
514
600
|
}
|
|
515
|
-
|
|
601
|
+
throw refused(`${file} exists; a real installation requires its packaged maintenance`);
|
|
516
602
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
603
|
+
removeBoundedRoot(path.resolve(root), {
|
|
604
|
+
uid: 0,
|
|
605
|
+
allowedEntries: PARTIAL_SYSTEM_ENTRIES,
|
|
606
|
+
io,
|
|
607
|
+
refused,
|
|
608
|
+
owner: 'root'
|
|
609
|
+
});
|
|
522
610
|
} catch (err) {
|
|
523
|
-
if (err?.code === '
|
|
611
|
+
if (err?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw err;
|
|
524
612
|
throw refused(err instanceof Error ? err.message : 'the tree could not be verified');
|
|
525
613
|
}
|
|
526
614
|
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-command",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.10",
|
|
4
|
+
"description": "Node Command CLI installer for the Node Command agent",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"license": "Apache-2.0",
|
|
6
7
|
"engines": {
|
|
7
|
-
"node": ">=
|
|
8
|
+
"node": ">=24"
|
|
8
9
|
},
|
|
9
10
|
"bin": {
|
|
10
11
|
"nc": "bin/nc.mjs",
|
|
11
|
-
"nodecommand": "bin/nc.mjs",
|
|
12
12
|
"nc-agent": "bin/nc.mjs",
|
|
13
|
+
"nodecommand": "bin/nc.mjs",
|
|
13
14
|
"node-command": "bin/nc.mjs"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"bin/",
|
|
17
|
-
"lib/"
|
|
18
|
-
|
|
18
|
+
"lib/",
|
|
19
|
+
"package.json"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
}
|
|
19
24
|
}
|