node-command 2.0.15 → 2.0.16
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 +384 -46
- package/lib/root-on.mjs +338 -0
- package/lib/setup.mjs +26 -1
- package/package.json +1 -1
package/bin/nc.mjs
CHANGED
|
@@ -30,12 +30,15 @@ import {
|
|
|
30
30
|
removePartialSystemRoot,
|
|
31
31
|
removePartialUserRoot,
|
|
32
32
|
resolveExecutable,
|
|
33
|
+
resolveInstallConsent,
|
|
33
34
|
setupTarget,
|
|
34
35
|
unreadableSystemInstallations,
|
|
35
36
|
maintainLinuxIdentity,
|
|
36
37
|
loadProtectedHelperProducer,
|
|
37
38
|
LINUX_PAIRED_UNINSTALL_ENTRY,
|
|
38
39
|
modeWarningFor,
|
|
40
|
+
PARTIAL_SYSTEM_ROOT,
|
|
41
|
+
partialEnrollmentSystemRoot,
|
|
39
42
|
runUninstallerScript,
|
|
40
43
|
uninstallFailureSuggestion,
|
|
41
44
|
uninstallerForRoot,
|
|
@@ -1562,22 +1565,51 @@ async function fetchPrivilegedInstallContext({ baseUrl, hostId, hostToken, priva
|
|
|
1562
1565
|
}
|
|
1563
1566
|
|
|
1564
1567
|
/**
|
|
1565
|
-
* Publishes the locally generated helper public key as a pending candidate.
|
|
1566
|
-
*
|
|
1568
|
+
* Publishes the locally generated helper ACK public key as a pending candidate.
|
|
1569
|
+
* The ACK key is the helper identity; privileged-helper-public.pem is the
|
|
1570
|
+
* control-plane pin and is never a candidate. Only the derived public key is
|
|
1571
|
+
* sent; a failure never rolls back the installed agent.
|
|
1567
1572
|
*/
|
|
1568
1573
|
async function publishPrivilegedHelperCandidate({ root, baseUrl, hostId, hostToken, privateKey }) {
|
|
1569
|
-
const
|
|
1574
|
+
const helperDir = path.join(root, 'libexec', 'privileged-helper');
|
|
1575
|
+
const bootstrapPath = path.join(helperDir, 'helper-bootstrap.json');
|
|
1576
|
+
const failed = { published: false, warning: 'Admin remains pending; candidate publication failed: protected helper ACK key not found or invalid.' };
|
|
1577
|
+
let ackKeyPath = path.join(helperDir, 'privileged-helper-ack-key.pem');
|
|
1578
|
+
let bootstrap;
|
|
1579
|
+
try {
|
|
1580
|
+
const bootstrapInfo = fs.lstatSync(bootstrapPath);
|
|
1581
|
+
if (!bootstrapInfo.isFile() || bootstrapInfo.isSymbolicLink()) return failed;
|
|
1582
|
+
bootstrap = JSON.parse(fs.readFileSync(bootstrapPath, 'utf8'));
|
|
1583
|
+
} catch (err) {
|
|
1584
|
+
if (err?.code !== 'ENOENT') return failed;
|
|
1585
|
+
}
|
|
1586
|
+
if (bootstrap && typeof bootstrap.ack_private_key_file === 'string' && bootstrap.ack_private_key_file) {
|
|
1587
|
+
if (!path.isAbsolute(bootstrap.ack_private_key_file) ||
|
|
1588
|
+
path.resolve(bootstrap.ack_private_key_file) !== bootstrap.ack_private_key_file) return failed;
|
|
1589
|
+
ackKeyPath = bootstrap.ack_private_key_file;
|
|
1590
|
+
}
|
|
1591
|
+
let ackKeyBytes;
|
|
1592
|
+
try {
|
|
1593
|
+
const ackInfo = fs.lstatSync(ackKeyPath);
|
|
1594
|
+
if (!ackInfo.isFile() || ackInfo.isSymbolicLink() ||
|
|
1595
|
+
(process.geteuid?.() === 0 && ackInfo.uid !== 0) || (ackInfo.mode & 0o777) !== 0o600) return failed;
|
|
1596
|
+
ackKeyBytes = fs.readFileSync(ackKeyPath);
|
|
1597
|
+
} catch {
|
|
1598
|
+
return failed;
|
|
1599
|
+
}
|
|
1600
|
+
let publicKey;
|
|
1570
1601
|
let pem;
|
|
1571
1602
|
try {
|
|
1572
|
-
const
|
|
1573
|
-
if (
|
|
1574
|
-
|
|
1603
|
+
const ackPrivateKey = crypto.createPrivateKey(ackKeyBytes);
|
|
1604
|
+
if (ackPrivateKey.asymmetricKeyType !== 'ed25519') return failed;
|
|
1605
|
+
publicKey = crypto.createPublicKey(ackPrivateKey);
|
|
1606
|
+
pem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
1575
1607
|
} catch {
|
|
1576
|
-
return
|
|
1608
|
+
return failed;
|
|
1609
|
+
} finally {
|
|
1610
|
+
ackKeyBytes.fill(0);
|
|
1577
1611
|
}
|
|
1578
1612
|
try {
|
|
1579
|
-
const publicKey = crypto.createPublicKey(pem);
|
|
1580
|
-
if (publicKey.asymmetricKeyType !== 'ed25519') throw new Error('helper public key is not Ed25519');
|
|
1581
1613
|
const candidateUrl = `${baseUrl}/api/clients/${encodeURIComponent(hostId)}/privileged-helper/candidate`;
|
|
1582
1614
|
assertCredentialOrigin(candidateUrl, { label: 'helper candidate URL', action: 'publish' });
|
|
1583
1615
|
const proof = createDeviceProofCli({ privateKey, hostId, method: 'POST', url: candidateUrl, credential: hostToken });
|
|
@@ -1590,16 +1622,142 @@ async function publishPrivilegedHelperCandidate({ root, baseUrl, hostId, hostTok
|
|
|
1590
1622
|
throw new Error(sanitizeForDisplay(res.data?.error || `HTTP ${res.status}`, 120));
|
|
1591
1623
|
}
|
|
1592
1624
|
const keyId = crypto.createHash('sha256').update(publicKey.export({ type: 'spki', format: 'der' })).digest('hex');
|
|
1593
|
-
|
|
1625
|
+
const candidate = res.data?.candidate;
|
|
1626
|
+
let candidateKey = null;
|
|
1627
|
+
try { candidateKey = candidate?.public_key ? crypto.createPublicKey(candidate.public_key) : null; } catch {}
|
|
1628
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate) ||
|
|
1629
|
+
!/^cand_[0-9a-f]{32}$/.test(String(candidate.candidate_generation || '')) ||
|
|
1630
|
+
candidate.helper_key_id !== keyId || !candidateKey ||
|
|
1631
|
+
candidateKey.export({ type: 'spki', format: 'der' }).compare(publicKey.export({ type: 'spki', format: 'der' })) !== 0) {
|
|
1632
|
+
throw new Error('the control plane did not return a matching candidate generation');
|
|
1633
|
+
}
|
|
1634
|
+
return { published: true, fingerprint: `sha256:${keyId}`, candidate_generation: candidate.candidate_generation,
|
|
1635
|
+
helper_key_id: keyId, public_key: pem, expires_at_ms: candidate.expires_at_ms };
|
|
1594
1636
|
} catch (err) {
|
|
1595
1637
|
const detail = sanitizeForDisplay(err instanceof Error ? err.message : String(err), 200);
|
|
1596
1638
|
return {
|
|
1597
1639
|
published: false,
|
|
1598
|
-
warning: `Admin remains pending; candidate publication failed: ${detail}. The
|
|
1640
|
+
warning: `Admin remains pending; candidate publication failed: ${detail}. The helper remains installed but unbound; re-run setup with root consent or use the documented break-glass maintenance path.`
|
|
1599
1641
|
};
|
|
1600
1642
|
}
|
|
1601
1643
|
}
|
|
1602
1644
|
|
|
1645
|
+
/**
|
|
1646
|
+
* Reads the protected helper bootstrap record written by the privileged
|
|
1647
|
+
* installer. The record supplies the exact tenant/control-plane/client tuple
|
|
1648
|
+
* and ACK key id that the signed challenge must match; it is never a source of
|
|
1649
|
+
* trust on its own (the producer verifies the pinned challenge before writing).
|
|
1650
|
+
* @param {string} root
|
|
1651
|
+
* @param {string} hostId
|
|
1652
|
+
* @param {{ helper_key_id: string }} candidate
|
|
1653
|
+
*/
|
|
1654
|
+
function readProtectedHelperBootstrap(root, hostId, candidate) {
|
|
1655
|
+
const file = path.join(root, 'libexec', 'privileged-helper', 'helper-bootstrap.json');
|
|
1656
|
+
const failed = (reason) => new PairingError('HELPER_BINDING_FAILED', `Protected helper bootstrap is not usable: ${reason}. The helper remains installed but unbound.`);
|
|
1657
|
+
let info;
|
|
1658
|
+
try {
|
|
1659
|
+
info = fs.lstatSync(file);
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
throw failed(err?.code === 'ENOENT' ? 'the helper bootstrap file is missing' : 'the helper bootstrap file cannot be inspected');
|
|
1662
|
+
}
|
|
1663
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536) throw failed('the helper bootstrap file is not a bounded regular file');
|
|
1664
|
+
let bootstrap;
|
|
1665
|
+
try {
|
|
1666
|
+
bootstrap = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1667
|
+
} catch {
|
|
1668
|
+
throw failed('the helper bootstrap file is not valid JSON');
|
|
1669
|
+
}
|
|
1670
|
+
if (!bootstrap || typeof bootstrap !== 'object' || Array.isArray(bootstrap) ||
|
|
1671
|
+
bootstrap.client_id !== hostId || bootstrap.root_consent !== true ||
|
|
1672
|
+
typeof bootstrap.tenant_id !== 'string' || !bootstrap.tenant_id ||
|
|
1673
|
+
typeof bootstrap.control_plane_id !== 'string' || !bootstrap.control_plane_id ||
|
|
1674
|
+
bootstrap.helper_key_id !== candidate.helper_key_id) {
|
|
1675
|
+
throw failed('the bootstrap tuple does not match the published candidate');
|
|
1676
|
+
}
|
|
1677
|
+
return bootstrap;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* Requests the signed install-binding challenge. Exactly one management source
|
|
1682
|
+
* authorizes the request server-side: the unexpired install authorization on
|
|
1683
|
+
* the Y path (no intent), or the consumed root-on intent id on the root-on
|
|
1684
|
+
* path. The device proof is the same one used by other client-authenticated
|
|
1685
|
+
* routes.
|
|
1686
|
+
* @param {{ baseUrl: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, candidate: { candidate_generation: string, public_key: string }, rootOnIntentId?: string|null }} options
|
|
1687
|
+
*/
|
|
1688
|
+
export async function requestPrivilegedHelperInstallBinding({ baseUrl, hostId, hostToken, privateKey, candidate, rootOnIntentId = null }) {
|
|
1689
|
+
const bindingUrl = `${baseUrl}/api/clients/${encodeURIComponent(hostId)}/privileged-helper/install-binding`;
|
|
1690
|
+
assertCredentialOrigin(bindingUrl, { label: 'helper install-binding URL', action: 'request' });
|
|
1691
|
+
const body = {
|
|
1692
|
+
candidate_generation: candidate.candidate_generation,
|
|
1693
|
+
public_key: candidate.public_key,
|
|
1694
|
+
expected_helper_key_binding_id: null
|
|
1695
|
+
};
|
|
1696
|
+
if (rootOnIntentId) body.root_on_intent_id = rootOnIntentId;
|
|
1697
|
+
const proof = createDeviceProofCli({ privateKey, hostId, method: 'POST', url: bindingUrl, credential: hostToken });
|
|
1698
|
+
const res = await requestJson('POST', bindingUrl, body, {
|
|
1699
|
+
Authorization: `NC-Device ${hostToken}`,
|
|
1700
|
+
'NC-Device-Proof': proof
|
|
1701
|
+
});
|
|
1702
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1703
|
+
const detail = sanitizeForDisplay(res.data?.error || res.data?.message || `HTTP ${res.status}`, 160);
|
|
1704
|
+
throw new PairingError('HELPER_BINDING_FAILED', `The control plane refused the helper install-binding request (${detail}). The helper remains installed but unbound.`);
|
|
1705
|
+
}
|
|
1706
|
+
const challenge = res.data?.challenge ?? res.data?.maintenance?.challenge;
|
|
1707
|
+
const bindingId = res.data?.helper_key_binding_id ?? res.data?.binding_id ??
|
|
1708
|
+
res.data?.binding?.helper_key_binding_id ?? res.data?.maintenance?.binding?.helper_key_binding_id;
|
|
1709
|
+
if (!challenge || typeof challenge !== 'object' || Array.isArray(challenge) ||
|
|
1710
|
+
typeof bindingId !== 'string' || !bindingId) {
|
|
1711
|
+
throw new PairingError('HELPER_BINDING_FAILED', 'The control plane returned no signed challenge/binding id. The helper remains installed but unbound.');
|
|
1712
|
+
}
|
|
1713
|
+
const appliedPermission = [res.data?.applied_permission, res.data?.permission?.applied_permission,
|
|
1714
|
+
res.data?.execution_permission?.applied_permission, res.data?.local_privilege?.applied_permission]
|
|
1715
|
+
.find((value) => value === 'admin' || value === 'user') ?? null;
|
|
1716
|
+
return { challenge, helper_key_binding_id: bindingId, applied_permission: appliedPermission };
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* Completes the first helper binding inside the same elevated setup: request
|
|
1721
|
+
* the signed challenge, verify it with the protected producer, write the
|
|
1722
|
+
* binding atomically and restart the helper. A failure is a typed fail-closed
|
|
1723
|
+
* error; the helper stays installed and unbound for a repairable retry.
|
|
1724
|
+
* @param {{ root: string, baseUrl: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, candidate: { candidate_generation: string, helper_key_id: string, public_key: string }, rootOnIntentId?: string|null, producer?: any }} options
|
|
1725
|
+
*/
|
|
1726
|
+
export async function completePrivilegedHelperBinding({ root, baseUrl, hostId, hostToken, privateKey, candidate, rootOnIntentId = null, producer = null }) {
|
|
1727
|
+
const response = await requestPrivilegedHelperInstallBinding({ baseUrl, hostId, hostToken, privateKey, candidate, rootOnIntentId });
|
|
1728
|
+
const bootstrap = readProtectedHelperBootstrap(root, hostId, candidate);
|
|
1729
|
+
const binding = {
|
|
1730
|
+
control_plane_id: bootstrap.control_plane_id,
|
|
1731
|
+
tenant_id: bootstrap.tenant_id,
|
|
1732
|
+
client_id: hostId,
|
|
1733
|
+
helper_key_binding_id: response.helper_key_binding_id,
|
|
1734
|
+
helper_key_id: candidate.helper_key_id,
|
|
1735
|
+
public_key: candidate.public_key,
|
|
1736
|
+
confirmed: true,
|
|
1737
|
+
active: false
|
|
1738
|
+
};
|
|
1739
|
+
const libexec = path.join(root, 'libexec');
|
|
1740
|
+
fs.mkdirSync(libexec, { recursive: true, mode: 0o755 });
|
|
1741
|
+
const tempDir = fs.mkdtempSync(path.join(libexec, '.nc-install-binding-'));
|
|
1742
|
+
try {
|
|
1743
|
+
fs.chmodSync(tempDir, 0o700);
|
|
1744
|
+
const bindingFile = path.join(tempDir, 'binding.json');
|
|
1745
|
+
const challengeFile = path.join(tempDir, 'challenge.json');
|
|
1746
|
+
fs.writeFileSync(bindingFile, JSON.stringify(binding), { mode: 0o600, flag: 'wx' });
|
|
1747
|
+
fs.writeFileSync(challengeFile, JSON.stringify(response.challenge), { mode: 0o600, flag: 'wx' });
|
|
1748
|
+
const module = producer || await loadProtectedHelperProducer(root);
|
|
1749
|
+
await module.configureProtectedHelperBinding({ root, bindingFile, challengeFile, activateLocally: true });
|
|
1750
|
+
} catch (err) {
|
|
1751
|
+
if (err instanceof PairingError) throw err;
|
|
1752
|
+
const detail = sanitizeForDisplay(err instanceof Error ? err.message : String(err), 200);
|
|
1753
|
+
throw new PairingError('HELPER_BINDING_FAILED', `Protected helper binding failed: ${detail}. The helper remains installed but unbound; no Admin readiness is claimed.`);
|
|
1754
|
+
} finally {
|
|
1755
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
|
1756
|
+
}
|
|
1757
|
+
return { bound: true, helper_key_binding_id: binding.helper_key_binding_id,
|
|
1758
|
+
fingerprint: `sha256:${binding.helper_key_id}`, applied_permission: response.applied_permission ?? null };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1603
1761
|
/**
|
|
1604
1762
|
* Downloads, verifies, extracts, and installs the agent package. Writes NO
|
|
1605
1763
|
* credentials — the env file must already exist before this runs.
|
|
@@ -1628,10 +1786,16 @@ async function publishPrivilegedHelperCandidate({ root, baseUrl, hostId, hostTok
|
|
|
1628
1786
|
* @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
|
|
1629
1787
|
* @returns {Promise<{ helperCandidate: { published: boolean, fingerprint?: string, warning?: string }|null }>} `helperCandidate` is null when no helper was requested.
|
|
1630
1788
|
*/
|
|
1631
|
-
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 } = {}) {
|
|
1789
|
+
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, rootOnIntentId = null, bindingProducer = null } = {}) {
|
|
1790
|
+
if (rootConsent === true && installMode === 'user') {
|
|
1791
|
+
throw new PairingError('ROOT_HELPER_REQUIRES_SYSTEM_INSTALL', 'Root-command consent requires a system install; no per-user install was created. Install in user mode, then run `sudo npx --yes node-command@latest root on <host_id>` to elevate this host.');
|
|
1792
|
+
}
|
|
1793
|
+
if (rootConsent === true && os.platform() !== 'linux') {
|
|
1794
|
+
throw new PairingError('ROOT_HELPER_UNSUPPORTED_PLATFORM', 'Root-command consent with the privileged helper is Linux-only. Install in user mode; this host does not claim Admin readiness.');
|
|
1795
|
+
}
|
|
1632
1796
|
ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
|
|
1633
1797
|
const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
|
|
1634
|
-
const helperRequested = rootConsent === true &&
|
|
1798
|
+
const helperRequested = rootConsent === true && os.platform() === 'linux';
|
|
1635
1799
|
const invokeInstaller = (scriptPath, installRoot) =>
|
|
1636
1800
|
runInstaller ? runInstaller(scriptPath, installRoot, modeEnv) : runInstallerScript(scriptPath, installRoot, modeEnv);
|
|
1637
1801
|
const root = ncRoot;
|
|
@@ -1654,6 +1818,9 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1654
1818
|
if (hasDeviceIdentity) {
|
|
1655
1819
|
assertCredentialOrigin(defaultBaseUrl, { label: 'agent server origin' });
|
|
1656
1820
|
}
|
|
1821
|
+
if (helperRequested && !hasDeviceIdentity) {
|
|
1822
|
+
throw new PairingError('HELPER_BINDING_FAILED', 'Root-command consent requires the existing host identity to publish and bind the helper. No Admin readiness was claimed; re-run setup with an enrollment.');
|
|
1823
|
+
}
|
|
1657
1824
|
/** @type {Record<string, any>} */
|
|
1658
1825
|
let activeRelease;
|
|
1659
1826
|
if (release && typeof release === 'object') {
|
|
@@ -1922,6 +2089,7 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1922
2089
|
}
|
|
1923
2090
|
|
|
1924
2091
|
let helperCandidate = null;
|
|
2092
|
+
let helperBinding = null;
|
|
1925
2093
|
if (helperRequested && hasDeviceIdentity) {
|
|
1926
2094
|
helperCandidate = await publishPrivilegedHelperCandidate({
|
|
1927
2095
|
root,
|
|
@@ -1930,11 +2098,24 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1930
2098
|
hostToken,
|
|
1931
2099
|
privateKey
|
|
1932
2100
|
});
|
|
1933
|
-
if (helperCandidate.published
|
|
1934
|
-
|
|
2101
|
+
if (helperCandidate.published !== true) {
|
|
2102
|
+
// One-session binding is the install contract: never report success with
|
|
2103
|
+
// a silently unbound helper. The agent stays installed and the next
|
|
2104
|
+
// setup/root-on can resume the same host identity.
|
|
2105
|
+
throw new PairingError('HELPER_BINDING_FAILED', `${helperCandidate.warning || 'The helper candidate could not be published.'} The agent is installed; the helper remains unbound and no Admin readiness is claimed.`);
|
|
1935
2106
|
}
|
|
2107
|
+
helperBinding = await completePrivilegedHelperBinding({
|
|
2108
|
+
root,
|
|
2109
|
+
baseUrl: defaultBaseUrl,
|
|
2110
|
+
hostId,
|
|
2111
|
+
hostToken,
|
|
2112
|
+
privateKey,
|
|
2113
|
+
candidate: helperCandidate,
|
|
2114
|
+
rootOnIntentId,
|
|
2115
|
+
producer: bindingProducer
|
|
2116
|
+
});
|
|
1936
2117
|
}
|
|
1937
|
-
return { helperCandidate };
|
|
2118
|
+
return { helperCandidate, helperBinding };
|
|
1938
2119
|
}
|
|
1939
2120
|
|
|
1940
2121
|
/**
|
|
@@ -2123,9 +2304,15 @@ export async function pairDeviceAndInstall({
|
|
|
2123
2304
|
intervalMs: verifyIntervalMs
|
|
2124
2305
|
});
|
|
2125
2306
|
writePairState(statePath, { host_id: hostId, phase: 'verified' });
|
|
2307
|
+
const helperBinding = installOutcome?.helperBinding ?? null;
|
|
2308
|
+
// Admin readiness is truthful only when the helper is actually bound and the
|
|
2309
|
+
// server-reported applied personal permission is still Admin. The install
|
|
2310
|
+
// answer alone is never evidence of readiness.
|
|
2311
|
+
const appliedPermission = executionInitialization?.permission?.applied_permission ?? null;
|
|
2312
|
+
const adminReady = helperBinding?.bound === true && appliedPermission === 'admin';
|
|
2126
2313
|
return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion, rootConsent: selectedRootConsent,
|
|
2127
|
-
privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable',
|
|
2128
|
-
helperCandidate: installOutcome?.helperCandidate ?? null };
|
|
2314
|
+
privilegeReadiness: helperBinding?.bound === true ? 'helper-ready' : selectedRootConsent ? 'pending' : 'unavailable',
|
|
2315
|
+
executionInitialization, helperCandidate: installOutcome?.helperCandidate ?? null, helperBinding, adminReady };
|
|
2129
2316
|
}
|
|
2130
2317
|
|
|
2131
2318
|
/**
|
|
@@ -2165,12 +2352,14 @@ function printCliHelp() {
|
|
|
2165
2352
|
' nodecommand setup [host_id token] install the agent (auto system/user mode; no args = browser device flow)',
|
|
2166
2353
|
' nodecommand setup --user force a per-user install (never requests sudo/UAC)',
|
|
2167
2354
|
' nodecommand --user same as `setup --user`',
|
|
2168
|
-
' nodecommand
|
|
2355
|
+
' nodecommand root on <host_id> elevate an existing user-mode Linux install to a system helper (interactive)',
|
|
2356
|
+
' nodecommand [--allow-root-commands] explicitly request privileged preparation (default No; one-session helper binding)',
|
|
2169
2357
|
' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
|
|
2170
2358
|
' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
|
|
2171
2359
|
' nodecommand --diagnostics preview a redacted diagnostics bundle (dry-run, writes nothing)',
|
|
2172
2360
|
' nodecommand --diagnostics --apply write the diagnostics bundle (.tar.gz) for a support ticket',
|
|
2173
2361
|
' nodecommand --diagnostics --apply --output <path> write the bundle to a chosen path',
|
|
2362
|
+
' nodecommand root --help show the root-on elevation help',
|
|
2174
2363
|
' nodecommand --help show this help',
|
|
2175
2364
|
'',
|
|
2176
2365
|
'Environment:',
|
|
@@ -2231,11 +2420,75 @@ export function isBareHostIdInvocation(args = []) {
|
|
|
2231
2420
|
return typeof hostId === 'string' && !hostId.startsWith('-') && HOST_ID_RE.test(hostId);
|
|
2232
2421
|
}
|
|
2233
2422
|
|
|
2423
|
+
function printRootHelp() {
|
|
2424
|
+
console.log(
|
|
2425
|
+
[
|
|
2426
|
+
'nodecommand root — one-time local elevation for a user-mode Linux host',
|
|
2427
|
+
'',
|
|
2428
|
+
'Usage:',
|
|
2429
|
+
' nodecommand root on <host_id> consume the Owner-issued intent and install/bind the system helper',
|
|
2430
|
+
' nodecommand root --help show this help',
|
|
2431
|
+
'',
|
|
2432
|
+
'The command requires an interactive terminal, real root (run it with sudo), an existing',
|
|
2433
|
+
'user-mode host identity, and a current Owner-issued Admin request in the dashboard.',
|
|
2434
|
+
'It preserves the host id, token, device key, server URL and local audit state.',
|
|
2435
|
+
'It never accepts secrets on the command line. Linux only.'
|
|
2436
|
+
].join('\n')
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/**
|
|
2441
|
+
* `nodecommand root on <host_id>`. Dispatch stays small: the implementation
|
|
2442
|
+
* lives in lib/root-on.mjs and is loaded only for this command.
|
|
2443
|
+
* @param {string[]} args
|
|
2444
|
+
*/
|
|
2445
|
+
export async function runRootCommand(args = []) {
|
|
2446
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
2447
|
+
printRootHelp();
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
assertKnownFlags(args, ['--help', '-h'], 'root');
|
|
2451
|
+
const positionals = args.filter((arg) => !arg.startsWith('-'));
|
|
2452
|
+
if (positionals.length !== 2 || positionals[0] !== 'on') {
|
|
2453
|
+
console.error('error: [INVALID_ROOT_ARGS] `root` takes exactly `on <host_id>`.');
|
|
2454
|
+
console.error('suggested_action: Run `sudo npx --yes node-command@latest root on <host_id>` with the canonical host id from the dashboard.');
|
|
2455
|
+
process.exit(1);
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
const hostId = positionals[1];
|
|
2459
|
+
if (!HOST_ID_RE.test(hostId)) {
|
|
2460
|
+
console.error('error: [INVALID_HOST_ID] The supplied host id is not a canonical nc_<64hex> id.');
|
|
2461
|
+
console.error('suggested_action: Copy the exact host id shown by the dashboard; no elevation was attempted.');
|
|
2462
|
+
process.exit(1);
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
const { runRootOn } = await import('../lib/root-on.mjs');
|
|
2466
|
+
const result = await runRootOn({ hostId });
|
|
2467
|
+
if (result.alreadyEnabled === true) {
|
|
2468
|
+
console.log(`helper-ready: host ${sanitizeForDisplay(hostId, 128)} is already bound; no changes were made.`);
|
|
2469
|
+
console.log('Admin-ready: pending (current execution permission is not verified by this command)');
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
if (result.helperReady !== true) {
|
|
2473
|
+
console.error('error: [HELPER_BINDING_FAILED] The system install did not complete the helper binding; no Admin readiness is claimed.');
|
|
2474
|
+
console.error('suggested_action: Preserve both installations and re-run `sudo npx --yes node-command@latest root on <host_id>` after resolving the reported install error.');
|
|
2475
|
+
process.exit(1);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
console.log(`helper-ready: host ${sanitizeForDisplay(hostId, 128)} is installed and bound.`);
|
|
2479
|
+
if (result.adminReady === true) console.log('Admin-ready: yes (current execution permission is Admin)');
|
|
2480
|
+
else console.log('Admin-ready: pending (current execution permission is not Admin)');
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2234
2483
|
async function main() {
|
|
2235
2484
|
const cliArgs = process.argv.slice(2);
|
|
2236
2485
|
// Subcommands route FIRST: otherwise `setup --diagnostics` or a stray flag
|
|
2237
2486
|
// would be swallowed by the global flag handlers instead of the command.
|
|
2238
2487
|
const [subcommand, ...subArgs] = cliArgs;
|
|
2488
|
+
if (subcommand === 'root') {
|
|
2489
|
+
await runRootCommand(subArgs);
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2239
2492
|
if (subcommand === 'setup') {
|
|
2240
2493
|
await runSetup(subArgs);
|
|
2241
2494
|
return;
|
|
@@ -2338,7 +2591,34 @@ export function suggestedActionFor(code) {
|
|
|
2338
2591
|
case 'SETUP_IN_PROGRESS':
|
|
2339
2592
|
return 'Another setup, install or uninstall owns this installation root. Wait for it to finish (or remove a stale lock after confirming no setup is running) and re-run.';
|
|
2340
2593
|
case 'HELPER_BINDING_FAILED':
|
|
2341
|
-
return '
|
|
2594
|
+
return 'The helper is installed but unbound. Re-run setup with root consent or use the documented break-glass maintenance path; no Admin readiness is claimed.';
|
|
2595
|
+
case 'ROOT_HELPER_REQUIRES_SYSTEM_INSTALL':
|
|
2596
|
+
return 'Install in user mode first, then run `sudo npx --yes node-command@latest root on <host_id>` to elevate this host.';
|
|
2597
|
+
case 'ROOT_HELPER_UNSUPPORTED_PLATFORM':
|
|
2598
|
+
return 'The privileged helper is Linux-only; install in user mode. This host does not claim Admin readiness.';
|
|
2599
|
+
case 'ROOT_ELEVATION_REQUIRED':
|
|
2600
|
+
return 'Provide administrator/root rights for a system install, or install in user mode and run `sudo npx --yes node-command@latest root on <host_id>`.';
|
|
2601
|
+
case 'ROOT_ON_UNSUPPORTED_PLATFORM':
|
|
2602
|
+
return 'The privileged helper elevation path is Linux-only; this host remains User-only and does not claim Admin readiness.';
|
|
2603
|
+
case 'ROOT_ON_NON_INTERACTIVE':
|
|
2604
|
+
return 'Run the command in an interactive terminal so the required local confirmation can be entered.';
|
|
2605
|
+
case 'ROOT_ON_NOT_ROOT':
|
|
2606
|
+
return 'Run `sudo npx --yes node-command@latest root on <host_id>` with real root/administrator rights.';
|
|
2607
|
+
case 'ROOT_ON_IDENTITY_MISSING':
|
|
2608
|
+
return 'Install the host in user mode first so its existing host id, token and device key exist; root-on never re-enrolls.';
|
|
2609
|
+
case 'ROOT_ON_IDENTITY_MISMATCH':
|
|
2610
|
+
return 'The local installation does not match the requested host id. Preserve it and use its own maintenance flow; no identity was replaced.';
|
|
2611
|
+
case 'ROOT_ON_CONFLICT':
|
|
2612
|
+
return 'A different system installation or service already exists. Preserve it and resolve the conflict through its maintenance flow before retrying.';
|
|
2613
|
+
case 'ROOT_ON_CONFIRMATION_DECLINED':
|
|
2614
|
+
return 'No elevation was performed. Re-run and confirm the exact host when you intend to enable Admin.';
|
|
2615
|
+
case 'ROOT_ON_INTENT_REQUIRED':
|
|
2616
|
+
case 'ROOT_ON_INTENT_EXPIRED':
|
|
2617
|
+
return 'Ask the workspace Owner to select Admin for this host again in the dashboard, then re-run `sudo npx --yes node-command@latest root on <host_id>` before the 10-minute intent expires.';
|
|
2618
|
+
case 'ROOT_ON_AUTH_FAILED':
|
|
2619
|
+
return 'The host identity was rejected; the intent was not consumed. Verify the stored host credentials and re-run.';
|
|
2620
|
+
case 'ROOT_ON_MIGRATION_FAILED':
|
|
2621
|
+
return 'The user installation was preserved and the partial system root was rolled back. Resolve the reported conflict and re-run.';
|
|
2342
2622
|
default:
|
|
2343
2623
|
return 'Re-run the command; use `nodecommand --help` for usage.';
|
|
2344
2624
|
}
|
|
@@ -2389,7 +2669,7 @@ function getCliVersion() {
|
|
|
2389
2669
|
* host id, install mode, CLI version, and verification status.
|
|
2390
2670
|
* @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
|
|
2391
2671
|
*/
|
|
2392
|
-
export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null, helperCandidate = null }) {
|
|
2672
|
+
export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null, helperCandidate = null, helperBinding = null, adminReady = false }) {
|
|
2393
2673
|
console.log(`\n✅ Setup complete for host ${sanitizeForDisplay(hostId, 128)}${resumed ? ' (resumed)' : ''}.`);
|
|
2394
2674
|
if (installMode) console.log(`installed as ${installMode}`);
|
|
2395
2675
|
if (version) console.log(`agent version: ${sanitizeForDisplay(version, 120)}`);
|
|
@@ -2404,15 +2684,18 @@ export function printSetupSuccess({ hostId, serverUrl, installMode = undefined,
|
|
|
2404
2684
|
} else {
|
|
2405
2685
|
console.log(`root command consent: ${rootConsent === true ? 'Yes' : 'No'}`);
|
|
2406
2686
|
if (rootConsent !== true) {
|
|
2407
|
-
console.log('local privilege readiness:
|
|
2408
|
-
|
|
2409
|
-
|
|
2687
|
+
console.log('local privilege readiness: user-mode (no privileged helper installed)');
|
|
2688
|
+
console.log(`Admin is available with one command: sudo npx --yes node-command@latest root on ${sanitizeForDisplay(hostId, 128)}`);
|
|
2689
|
+
} else if (helperBinding?.bound === true) {
|
|
2690
|
+
const match = /^sha256:([0-9a-f]{64})$/i.exec(String(helperBinding.fingerprint || ''));
|
|
2410
2691
|
const shortFingerprint = match ? `sha256:${match[1].slice(0, 16).toLowerCase()}` : null;
|
|
2411
|
-
console.log(`local privilege readiness:
|
|
2692
|
+
console.log(`local privilege readiness: helper-ready${shortFingerprint ? ` (fingerprint: ${shortFingerprint})` : ''}`);
|
|
2693
|
+
if (adminReady === true) console.log('Admin-ready: yes (current execution permission is Admin)');
|
|
2694
|
+
else console.log('Admin-ready: pending (current execution permission is not Admin)');
|
|
2412
2695
|
} else if (helperCandidate && typeof helperCandidate.warning === 'string' && helperCandidate.warning) {
|
|
2413
2696
|
console.log(`local privilege readiness: ${helperCandidate.warning}`);
|
|
2414
2697
|
} else {
|
|
2415
|
-
console.log('local privilege readiness:
|
|
2698
|
+
console.log('local privilege readiness: helper installed but unbound; re-run setup with root consent or use the documented break-glass maintenance path');
|
|
2416
2699
|
}
|
|
2417
2700
|
const receipt = executionInitialization;
|
|
2418
2701
|
const fields = ['outcome', 'reason', 'subject_user_id', 'permission'];
|
|
@@ -2528,18 +2811,20 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2528
2811
|
' nodecommand setup install via the browser device flow (auto system/user mode)',
|
|
2529
2812
|
' nodecommand setup <host_id> <token> install with a dashboard enrollment token (legacy positional form)',
|
|
2530
2813
|
'',
|
|
2814
|
+
' nodecommand root on <host_id> elevate an existing user-mode Linux install to a system helper',
|
|
2531
2815
|
' nodecommand setup --maintain-identity <host_id> explicit root-only selected identity migration (NC_ROOT required)',
|
|
2532
2816
|
' nodecommand setup --configure-helper-binding <host_id> <management_response.json> <signed_challenge.json>',
|
|
2533
2817
|
' --activate-initial-binding explicitly activates only the local initial binding and restarts unsynchronized.',
|
|
2818
|
+
' BREAK-GLASS ONLY: explicit local recovery maintenance, never part of the normal install flow.',
|
|
2534
2819
|
'',
|
|
2535
|
-
'
|
|
2536
|
-
'
|
|
2820
|
+
'The install asks once: Allow root/administrator commands through Node Command? [y/N]',
|
|
2821
|
+
' Y = system install that provisions, binds and restarts the privileged helper in the same run.',
|
|
2822
|
+
' N = user-mode install; Admin is then available with `sudo npx --yes node-command@latest root on <host_id>`.',
|
|
2823
|
+
'The explicit per-user switch always selects user mode and never asks or upgrades.',
|
|
2824
|
+
'Noninteractive setup defaults to No; --allow-root-commands explicitly requests Y.',
|
|
2537
2825
|
'Windows/Linux retain their existing automatic system/user elevation behavior.',
|
|
2538
|
-
'
|
|
2539
|
-
'
|
|
2540
|
-
'New setup asks: Allow root/administrator commands through Node Command? [y/N]',
|
|
2541
|
-
'Noninteractive setup defaults to No; --allow-root-commands explicitly requests preparation.',
|
|
2542
|
-
'Admin stays unavailable until protected helper binding and management approval are verified.'
|
|
2826
|
+
'macOS: fresh non-root installs use the per-user LaunchAgent without sudo;',
|
|
2827
|
+
'explicit root uses system mode when root consent is Y. Existing roots/modes are preserved or setup stops.'
|
|
2543
2828
|
].join('\n')
|
|
2544
2829
|
);
|
|
2545
2830
|
return;
|
|
@@ -2548,6 +2833,9 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2548
2833
|
// Explicit per-user mode: never elevate, and never degrade silently into a
|
|
2549
2834
|
// competing user install beside a system tree the caller cannot inspect.
|
|
2550
2835
|
const forceUser = args.includes('--user');
|
|
2836
|
+
if (forceUser && args.includes('--allow-root-commands')) {
|
|
2837
|
+
throw new PairingError('INVALID_SETUP_ARGS', '--user selects a per-user installation without the privileged helper and cannot be combined with --allow-root-commands. Choose one: Y for a system install with Admin, or --user for User-only.');
|
|
2838
|
+
}
|
|
2551
2839
|
if (forceUser && (args.includes('--maintain-identity') || args.includes('--configure-helper-binding'))) {
|
|
2552
2840
|
throw new PairingError('INVALID_SETUP_ARGS', '--user selects a per-user installation and cannot be combined with selected-installation maintenance.');
|
|
2553
2841
|
}
|
|
@@ -2693,6 +2981,24 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2693
2981
|
}
|
|
2694
2982
|
};
|
|
2695
2983
|
try {
|
|
2984
|
+
// A rejected/expired pre-redemption enrollment leaves only the device key
|
|
2985
|
+
// and enrollment state under /opt/nc/config. That exact residue is not an
|
|
2986
|
+
// installation, but setupTarget refuses any existing root without a host
|
|
2987
|
+
// environment, so it would block every retry. When this invocation carries
|
|
2988
|
+
// fresh enrollment material, clear exactly that bounded root through the
|
|
2989
|
+
// same root-only removal the uninstall path uses; every other shape (host
|
|
2990
|
+
// env, app, units, helpers or foreign entries) still fails closed.
|
|
2991
|
+
if (positionals.length > 0 && installMode === 'system' &&
|
|
2992
|
+
isPartialSystemRemovalCandidate(PARTIAL_SYSTEM_ROOT) &&
|
|
2993
|
+
partialEnrollmentSystemRoot(PARTIAL_SYSTEM_ROOT)) {
|
|
2994
|
+
console.log(`Clearing the partial enrollment residue at ${PARTIAL_SYSTEM_ROOT} before a fresh setup (no host configuration or application payload was present).`);
|
|
2995
|
+
const partialLock = acquireInstallLock(PARTIAL_SYSTEM_ROOT);
|
|
2996
|
+
try {
|
|
2997
|
+
removePartialSystemRoot(PARTIAL_SYSTEM_ROOT);
|
|
2998
|
+
} finally {
|
|
2999
|
+
partialLock.release();
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
2696
3002
|
if (!unreadableSystemRoot) {
|
|
2697
3003
|
const target = setupTarget({ mode: installMode });
|
|
2698
3004
|
({ mode: installMode, ncRoot } = target);
|
|
@@ -2779,6 +3085,32 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2779
3085
|
}
|
|
2780
3086
|
return;
|
|
2781
3087
|
}
|
|
3088
|
+
// The install answer is the only local root decision. Ask exactly once,
|
|
3089
|
+
// before the final mode/elevation selection, and carry it through a single
|
|
3090
|
+
// elevation without asking again. `--user` never asks or upgrades.
|
|
3091
|
+
const explicitRootConsent = args.includes('--allow-root-commands');
|
|
3092
|
+
let rootConsentAnswer = false;
|
|
3093
|
+
if (!forceUser) {
|
|
3094
|
+
if (explicitRootConsent) rootConsentAnswer = true;
|
|
3095
|
+
else if (process.env.NC_ELEVATED === '1' && process.env.NC_ROOT_CONSENT_CONFIRMED === '1') rootConsentAnswer = true;
|
|
3096
|
+
else rootConsentAnswer = await promptRootCommandConsent();
|
|
3097
|
+
}
|
|
3098
|
+
// --user never probes elevation (the explicit switch is final). macOS never
|
|
3099
|
+
// auto-elevates, so it never probes either. Only an explicit Y on Linux or
|
|
3100
|
+
// Windows may ask the platform whether elevation is available.
|
|
3101
|
+
const consentPlatform = os.platform();
|
|
3102
|
+
const alreadyElevated = isElevated();
|
|
3103
|
+
const mayProbeElevation = !forceUser && rootConsentAnswer === true && !alreadyElevated &&
|
|
3104
|
+
(consentPlatform === 'linux' || consentPlatform === 'win32');
|
|
3105
|
+
const consentPlan = resolveInstallConsent({ forceUser, explicitOptIn: explicitRootConsent, answer: rootConsentAnswer,
|
|
3106
|
+
platform: consentPlatform, elevated: alreadyElevated, elevationAvailable: mayProbeElevation ? canElevate() : false });
|
|
3107
|
+
if (consentPlan.error) {
|
|
3108
|
+
reportSetupError(new PairingError(consentPlan.error, `Root consent was selected, but this invocation cannot obtain administrator rights. No per-user install was created. Install in user mode, then run \`sudo npx --yes node-command@latest root on ${positionals[0] || '<host_id>'}\`.`));
|
|
3109
|
+
process.exit(1);
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
installMode = consentPlan.installMode;
|
|
3113
|
+
if (!existingTarget) ncRoot = getNcRootForMode(installMode);
|
|
2782
3114
|
// macOS has already resolved its user/system boundary without automatic
|
|
2783
3115
|
// elevation. Linux requests sudo; Windows requests UAC (Start-Process
|
|
2784
3116
|
// -Verb RunAs) so the install lands machine-wide at the canonical system
|
|
@@ -2786,21 +3118,24 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2786
3118
|
// never silently degrades a system install into %LOCALAPPDATA%. An
|
|
2787
3119
|
// unreadable canonical system installation also forces this branch: without
|
|
2788
3120
|
// elevation it is unknowable and must not become a per-user competitor.
|
|
2789
|
-
if (unreadableSystemRoot &&
|
|
3121
|
+
if (unreadableSystemRoot && !consentPlan.elevationRequired) {
|
|
2790
3122
|
console.error(`error: [INSTALL_FAILED] An existing system installation at ${unreadableSystemRoot} cannot be inspected without administrator rights.`);
|
|
2791
3123
|
console.error(forceUser
|
|
2792
3124
|
? 'suggested_action: --user cannot create a per-user installation beside it; re-run without --user with Administrator/root rights so the existing installation can be inspected. No per-user installation was created.'
|
|
2793
|
-
: 'suggested_action: Re-run
|
|
3125
|
+
: 'suggested_action: Re-run with Y/--allow-root-commands and Administrator/root rights so the existing installation can be inspected; no per-user installation was created beside it.');
|
|
2794
3126
|
process.exit(1);
|
|
2795
3127
|
return;
|
|
2796
3128
|
}
|
|
2797
|
-
if (
|
|
3129
|
+
if (consentPlan.elevationRequired && !process.env.NC_ELEVATED) {
|
|
2798
3130
|
// A legacy positional token must never enter sudo/UAC argv: it travels in
|
|
2799
3131
|
// a 0600 owner-only temp file whose path alone is re-executed.
|
|
2800
3132
|
const tokenFile = positionals.length === 2 ? createEnrollmentTokenFile(positionals[1]) : null;
|
|
2801
|
-
const
|
|
3133
|
+
const baseElevatedArgs = tokenFile
|
|
2802
3134
|
? buildElevatedSetupArgs(process.argv.slice(1), args, args.lastIndexOf(positionals[1]), tokenFile.filePath)
|
|
2803
|
-
:
|
|
3135
|
+
: process.argv.slice(1);
|
|
3136
|
+
// Carry the single Y answer through the elevation without re-prompting.
|
|
3137
|
+
const elevatedArgs = baseElevatedArgs.includes('--allow-root-commands')
|
|
3138
|
+
? baseElevatedArgs : [...baseElevatedArgs, '--allow-root-commands'];
|
|
2804
3139
|
try {
|
|
2805
3140
|
if (os.platform() === 'win32') {
|
|
2806
3141
|
if (process.stdin.isTTY) {
|
|
@@ -2811,8 +3146,8 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2811
3146
|
process.exit(0);
|
|
2812
3147
|
}
|
|
2813
3148
|
}
|
|
2814
|
-
console.error('error: [
|
|
2815
|
-
console.error('suggested_action:
|
|
3149
|
+
console.error('error: [ROOT_ELEVATION_REQUIRED] Elevation was unavailable or declined; no system-wide installation was performed and no per-user fallback was attempted.');
|
|
3150
|
+
console.error('suggested_action: Run as Administrator in an elevated shell for a system-wide install at C:\\ProgramData\\NC, or install in user mode and then run `node-command root on <host_id>`.');
|
|
2816
3151
|
process.exit(1);
|
|
2817
3152
|
}
|
|
2818
3153
|
console.log('Admin rights required for a system-wide install. Requesting sudo…');
|
|
@@ -2821,20 +3156,21 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2821
3156
|
// The caller's PATH is never forwarded into the privileged re-exec.
|
|
2822
3157
|
const elevationEnv = {
|
|
2823
3158
|
PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
|
|
2824
|
-
NC_ELEVATED: '1'
|
|
3159
|
+
NC_ELEVATED: '1',
|
|
3160
|
+
NC_ROOT_CONSENT_CONFIRMED: '1'
|
|
2825
3161
|
};
|
|
2826
3162
|
if (process.env.TERM) elevationEnv.TERM = process.env.TERM;
|
|
2827
3163
|
let sudo;
|
|
2828
3164
|
try {
|
|
2829
3165
|
sudo = resolveExecutable('sudo');
|
|
2830
3166
|
} catch (err) {
|
|
2831
|
-
console.error(`error: [
|
|
3167
|
+
console.error(`error: [ROOT_ELEVATION_REQUIRED] ${err.message}`);
|
|
2832
3168
|
console.error('suggested_action: Install an absolute sudo binary or run the system installer explicitly with administrator rights; no per-user fallback was attempted for an unsafe elevation path.');
|
|
2833
3169
|
process.exit(1);
|
|
2834
3170
|
}
|
|
2835
3171
|
const elevated = child_process.spawnSync(
|
|
2836
3172
|
sudo,
|
|
2837
|
-
['--preserve-env=NC_ELEVATED', process.execPath, ...
|
|
3173
|
+
['--preserve-env=NC_ELEVATED,NC_ROOT_CONSENT_CONFIRMED', process.execPath, ...elevatedArgs],
|
|
2838
3174
|
{ stdio: 'inherit', env: elevationEnv }
|
|
2839
3175
|
);
|
|
2840
3176
|
if (elevated && elevated.status === 0) {
|
|
@@ -2864,8 +3200,8 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2864
3200
|
// No silent fallback after an attempted elevation: a per-user install here
|
|
2865
3201
|
// would compete with the expected system install and could prompt for a
|
|
2866
3202
|
// second enrollment token in the same run. Fail closed instead.
|
|
2867
|
-
console.error('error: [
|
|
2868
|
-
console.error('suggested_action:
|
|
3203
|
+
console.error('error: [ROOT_ELEVATION_REQUIRED] Elevation was unavailable or declined; no system-wide installation was performed and no per-user fallback was attempted.');
|
|
3204
|
+
console.error('suggested_action: Provide administrator/root rights for a machine-wide installation, or install in user mode and then run `sudo npx --yes node-command@latest root on <host_id>`.');
|
|
2869
3205
|
process.exit(1);
|
|
2870
3206
|
return;
|
|
2871
3207
|
}
|
|
@@ -2895,7 +3231,9 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
2895
3231
|
}
|
|
2896
3232
|
}
|
|
2897
3233
|
|
|
2898
|
-
|
|
3234
|
+
// The answer was resolved once before elevation; pass the boolean so a
|
|
3235
|
+
// re-entrant prompt can never contradict the selected install mode.
|
|
3236
|
+
const rootConsent = rootConsentAnswer;
|
|
2899
3237
|
if (positionals.length === 2) {
|
|
2900
3238
|
const [hostId, enrollmentToken] = positionals;
|
|
2901
3239
|
try {
|
package/lib/root-on.mjs
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import child_process from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
PairingError,
|
|
7
|
+
HOST_ID_RE,
|
|
8
|
+
assertCredentialOrigin,
|
|
9
|
+
confirmPrompt,
|
|
10
|
+
createDeviceProofCli,
|
|
11
|
+
downloadAndInstallAgent,
|
|
12
|
+
getDeviceKeyPath,
|
|
13
|
+
getEnvFilePath,
|
|
14
|
+
loadDeviceKeyPair,
|
|
15
|
+
loadValidHostEnv,
|
|
16
|
+
requestJson,
|
|
17
|
+
sanitizeForDisplay
|
|
18
|
+
} from '../bin/nc.mjs';
|
|
19
|
+
import { getNcRootForMode, probeInstallRoot } from './setup.mjs';
|
|
20
|
+
|
|
21
|
+
const CANONICAL_SYSTEM_ROOT = '/opt/nc';
|
|
22
|
+
const DEFAULT_JOURNAL_FILE = '/var/lib/nc-cli/root-on-journal.json';
|
|
23
|
+
const PRESERVED_CONFIG_FILES = ['nc-client.env', 'nc-device-key.pem', 'nc-enroll-state.json'];
|
|
24
|
+
const MIGRATED_ENV_REMOVALS = /^(NC_ROOT|NC_SERVICE_MODE|NC_SERVICE_UNIT|NC_OPERATOR_USER|NC_AUDIT_FILE|NC_SERVER_LOG_FILE|NC_TUNNEL_LOG_FILE|NC_STATE_FILE|NC_CLIENT_LOG|NC_LOCAL_AUDIT_FILE)=/;
|
|
25
|
+
|
|
26
|
+
const fail = (code, message) => new PairingError(code, message);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the invoking operator's home when the command runs through sudo.
|
|
30
|
+
* The user-mode installation lives under that account, not under root's HOME.
|
|
31
|
+
* @param {{ env?: NodeJS.ProcessEnv, io?: Pick<typeof fs, 'readFileSync'> }} [options]
|
|
32
|
+
*/
|
|
33
|
+
export function resolveOperatorHome({ env = process.env, io = fs } = {}) {
|
|
34
|
+
const user = env.SUDO_USER || env.NC_OPERATOR_USER;
|
|
35
|
+
if (!user || user === 'root') return os.homedir();
|
|
36
|
+
try {
|
|
37
|
+
for (const line of io.readFileSync('/etc/passwd', 'utf8').split('\n')) {
|
|
38
|
+
const parts = line.split(':');
|
|
39
|
+
if (parts[0] === user && parts[5]) return parts[5];
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
return os.homedir();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Locate the existing user-mode installation for this host. Root-on never
|
|
47
|
+
* re-enrolls: a missing or mismatched identity fails closed before any
|
|
48
|
+
* mutation.
|
|
49
|
+
* @param {{ hostId: string, platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: typeof fs }} [options]
|
|
50
|
+
*/
|
|
51
|
+
export function locateUserInstallation({ hostId, platform = os.platform(), env = process.env, homedir = resolveOperatorHome({ env }), io = fs } = {}) {
|
|
52
|
+
if (!HOST_ID_RE.test(String(hostId || ''))) throw fail('INVALID_HOST_ID', 'The supplied host id is not a canonical nc_<64hex> id.');
|
|
53
|
+
const candidates = [];
|
|
54
|
+
if (env.NC_ROOT && path.resolve(env.NC_ROOT) !== CANONICAL_SYSTEM_ROOT) candidates.push(path.resolve(env.NC_ROOT));
|
|
55
|
+
candidates.push(path.resolve(getNcRootForMode('user', { platform, env: { ...env, NC_ROOT: undefined }, homedir })));
|
|
56
|
+
candidates.push(path.resolve(getNcRootForMode('user', { platform, env, homedir })));
|
|
57
|
+
let mismatch = null;
|
|
58
|
+
for (const sourceRoot of [...new Set(candidates)]) {
|
|
59
|
+
if (probeInstallRoot(sourceRoot, io) !== 'install') continue;
|
|
60
|
+
const localEnv = loadValidHostEnv(getEnvFilePath(sourceRoot));
|
|
61
|
+
if (!localEnv) continue;
|
|
62
|
+
// Root-on elevates a user-mode installation; an already-system tree is a
|
|
63
|
+
// different maintenance target, not a source to copy from.
|
|
64
|
+
if (localEnv.NC_SERVICE_MODE === 'system') continue;
|
|
65
|
+
if (localEnv.NC_CLIENT_ID !== hostId) {
|
|
66
|
+
mismatch = mismatch || localEnv.NC_CLIENT_ID;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
let key;
|
|
70
|
+
try {
|
|
71
|
+
key = loadDeviceKeyPair(getDeviceKeyPath(sourceRoot));
|
|
72
|
+
} catch {
|
|
73
|
+
throw fail('ROOT_ON_IDENTITY_MISSING', `The user installation at ${sourceRoot} has no usable device key; no elevation was attempted.`);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
sourceRoot,
|
|
77
|
+
hostId,
|
|
78
|
+
hostToken: localEnv.NC_CLIENT_TOKEN,
|
|
79
|
+
serverUrl: String(localEnv.NC_SERVER_URL).replace(/\/$/, ''),
|
|
80
|
+
privateKey: key.privateKey
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (mismatch) {
|
|
84
|
+
throw fail('ROOT_ON_IDENTITY_MISMATCH', `The local installation belongs to ${sanitizeForDisplay(mismatch, 128)}, not ${sanitizeForDisplay(hostId, 128)}. No identity was replaced.`);
|
|
85
|
+
}
|
|
86
|
+
throw fail('ROOT_ON_IDENTITY_MISSING', `No user-mode installation for host ${sanitizeForDisplay(hostId, 128)} was found. Install the host in user mode first; root-on never re-enrolls.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Inspect the canonical system root before consuming an intent. A different
|
|
91
|
+
* installation, a partial tree, or a competing service is a typed conflict;
|
|
92
|
+
* the same host with a bound helper is an idempotent no-op.
|
|
93
|
+
* @param {{ targetRoot?: string, hostId: string, io?: typeof fs, serviceFile?: string }} [options]
|
|
94
|
+
*/
|
|
95
|
+
export function inspectRootOnTarget({ targetRoot = CANONICAL_SYSTEM_ROOT, hostId, io = fs, serviceFile = '/etc/systemd/system/nc-client.service' } = {}) {
|
|
96
|
+
let status;
|
|
97
|
+
try {
|
|
98
|
+
status = probeInstallRoot(targetRoot, io);
|
|
99
|
+
} catch {
|
|
100
|
+
return { state: 'conflict', reason: 'the system root cannot be inspected' };
|
|
101
|
+
}
|
|
102
|
+
if (status === 'absent') {
|
|
103
|
+
try {
|
|
104
|
+
if (io.lstatSync(serviceFile)) return { state: 'conflict', reason: 'a competing system service already exists' };
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err?.code !== 'ENOENT') return { state: 'conflict', reason: 'the system service state cannot be inspected' };
|
|
107
|
+
}
|
|
108
|
+
return { state: 'absent' };
|
|
109
|
+
}
|
|
110
|
+
if (status !== 'install') return { state: 'conflict', reason: 'the system root is not a verifiable installation' };
|
|
111
|
+
const targetEnv = loadValidHostEnv(getEnvFilePath(targetRoot));
|
|
112
|
+
if (!targetEnv || targetEnv.NC_CLIENT_ID !== hostId) return { state: 'conflict', reason: 'the system root belongs to a different host' };
|
|
113
|
+
try {
|
|
114
|
+
const config = JSON.parse(fs.readFileSync(path.join(targetRoot, 'libexec', 'privileged-helper', 'privileged-helper.json'), 'utf8'));
|
|
115
|
+
const binding = config?.helper_key_binding;
|
|
116
|
+
if (config?.client_id === hostId && binding?.active === true &&
|
|
117
|
+
typeof binding.helper_key_binding_id === 'string' && binding.helper_key_binding_id) {
|
|
118
|
+
return { state: 'already-enabled' };
|
|
119
|
+
}
|
|
120
|
+
} catch {}
|
|
121
|
+
return { state: 'conflict', reason: 'the system root is installed but its helper is not bound' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Consume the Owner-issued, single-use root-on intent with the existing host
|
|
126
|
+
* device identity. Authentication/tenant failures never burn the intent: the
|
|
127
|
+
* CLI makes exactly one request and the control plane owns the atomic CAS.
|
|
128
|
+
* @param {{ baseUrl: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, request?: typeof requestJson }} options
|
|
129
|
+
*/
|
|
130
|
+
export async function consumeRootOnIntent({ baseUrl, hostId, hostToken, privateKey, request = requestJson }) {
|
|
131
|
+
const url = `${baseUrl}/api/clients/${encodeURIComponent(hostId)}/root-on/consume`;
|
|
132
|
+
assertCredentialOrigin(url, { label: 'root-on consume URL', action: 'consume' });
|
|
133
|
+
const proof = createDeviceProofCli({ privateKey, hostId, method: 'POST', url, credential: hostToken });
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
response = await request('POST', url, {}, {
|
|
137
|
+
Authorization: `NC-Device ${hostToken}`,
|
|
138
|
+
'NC-Device-Proof': proof
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
throw fail('ROOT_ON_AUTH_FAILED', `Could not reach the root-on consume endpoint (${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 160)}). The intent was not consumed; no elevation was attempted.`);
|
|
142
|
+
}
|
|
143
|
+
const detail = sanitizeForDisplay(response?.data?.error || response?.data?.message || `HTTP ${response?.status}`, 160);
|
|
144
|
+
if (response?.status === 401 || response?.status === 403) {
|
|
145
|
+
throw fail('ROOT_ON_AUTH_FAILED', `The host identity was rejected (${detail}). The intent was not consumed; no elevation was attempted.`);
|
|
146
|
+
}
|
|
147
|
+
if (response?.status === 404 || response?.status === 409 || response?.status === 410) {
|
|
148
|
+
throw fail('ROOT_ON_INTENT_REQUIRED', `No valid Owner-issued root-on intent is available for this host (${detail}). Ask the Owner to select Admin again, then re-run before the 10-minute intent expires.`);
|
|
149
|
+
}
|
|
150
|
+
if (!response || response.status < 200 || response.status >= 300) {
|
|
151
|
+
throw fail('ROOT_ON_INTENT_INVALID', `The root-on consume request failed (${detail}). No elevation was attempted.`);
|
|
152
|
+
}
|
|
153
|
+
const data = response.data;
|
|
154
|
+
const intentId = data?.intent_id ?? data?.root_on_intent_id;
|
|
155
|
+
if (typeof intentId !== 'string' || !/^[A-Za-z0-9_.:-]{1,256}$/.test(intentId)) {
|
|
156
|
+
throw fail('ROOT_ON_INTENT_INVALID', 'The control plane returned no valid root-on intent id. No elevation was attempted.');
|
|
157
|
+
}
|
|
158
|
+
const permission = [data?.applied_permission, data?.permission?.applied_permission, data?.execution_permission?.applied_permission]
|
|
159
|
+
.find((value) => value === 'admin' || value === 'user') ?? null;
|
|
160
|
+
return { intent_id: intentId, release: data?.release && typeof data.release === 'object' ? data.release : null, permission };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Journal and copy the existing host identity into the canonical system root.
|
|
165
|
+
* The user installation is never deleted; on failure only the partial system
|
|
166
|
+
* root created by this invocation is removed.
|
|
167
|
+
* @param {{ sourceRoot: string, targetRoot?: string, hostId: string, journalFile?: string, io?: typeof fs, run?: typeof child_process.spawnSync }} options
|
|
168
|
+
*/
|
|
169
|
+
export function migrateUserInstallation({ sourceRoot, targetRoot = CANONICAL_SYSTEM_ROOT, hostId, journalFile = DEFAULT_JOURNAL_FILE, io = fs, run = child_process.spawnSync }) {
|
|
170
|
+
const sourceEnvPath = getEnvFilePath(sourceRoot);
|
|
171
|
+
const sourceEnv = loadValidHostEnv(sourceEnvPath);
|
|
172
|
+
if (!sourceEnv || sourceEnv.NC_CLIENT_ID !== hostId) {
|
|
173
|
+
throw fail('ROOT_ON_IDENTITY_MISMATCH', 'The user installation identity does not match the requested host id. No migration was attempted.');
|
|
174
|
+
}
|
|
175
|
+
let targetExists = false;
|
|
176
|
+
try {
|
|
177
|
+
targetExists = Boolean(io.lstatSync(targetRoot));
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err?.code !== 'ENOENT') throw fail('ROOT_ON_CONFLICT', `The target root ${targetRoot} cannot be inspected. No migration was attempted.`);
|
|
180
|
+
}
|
|
181
|
+
if (targetExists) throw fail('ROOT_ON_CONFLICT', `A system installation already exists at ${targetRoot}. No migration was attempted.`);
|
|
182
|
+
|
|
183
|
+
const sourceEnvText = io.readFileSync(sourceEnvPath, 'utf8');
|
|
184
|
+
const envOperator = /^NC_OPERATOR_USER=([A-Za-z_][A-Za-z0-9_-]*)\s*$/m.exec(sourceEnvText)?.[1];
|
|
185
|
+
const sudoOperator = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(process.env.SUDO_USER || '') ? process.env.SUDO_USER : null;
|
|
186
|
+
const operator = [envOperator, sudoOperator].find((value) => value && value !== 'root') || null;
|
|
187
|
+
const userSystemctl = (args) => {
|
|
188
|
+
if (!operator) return;
|
|
189
|
+
try { run('/usr/bin/runuser', ['-u', operator, '--', '/usr/bin/systemctl', '--user', ...args], { stdio: 'ignore' }); } catch {}
|
|
190
|
+
};
|
|
191
|
+
const journal = { version: 1, host_id: hostId, source_root: sourceRoot, target_root: targetRoot, created_at: new Date().toISOString(), steps: [] };
|
|
192
|
+
const writeJournal = (step) => {
|
|
193
|
+
journal.steps.push(step);
|
|
194
|
+
io.writeFileSync(journalFile, JSON.stringify(journal) + '\n', { mode: 0o600 });
|
|
195
|
+
};
|
|
196
|
+
const rollback = () => {
|
|
197
|
+
userSystemctl(['enable', '--now', 'nc-client.service']);
|
|
198
|
+
for (const args of [
|
|
199
|
+
['disable', '--now', 'nc-client.service'],
|
|
200
|
+
['disable', '--now', 'nc-privileged-helper.socket'],
|
|
201
|
+
['disable', '--now', 'nc-privileged-helper.service']
|
|
202
|
+
]) {
|
|
203
|
+
try { run('/usr/bin/systemctl', args, { stdio: 'ignore' }); } catch {}
|
|
204
|
+
}
|
|
205
|
+
try { io.rmSync(targetRoot, { recursive: true, force: true }); } catch {}
|
|
206
|
+
try { io.rmSync(journalFile, { force: true }); } catch {}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
io.mkdirSync(path.dirname(journalFile), { recursive: true, mode: 0o750 });
|
|
211
|
+
writeJournal('start');
|
|
212
|
+
io.mkdirSync(path.join(targetRoot, 'config'), { recursive: true, mode: 0o750 });
|
|
213
|
+
io.mkdirSync(path.join(targetRoot, 'logs'), { recursive: true, mode: 0o750 });
|
|
214
|
+
writeJournal('directories');
|
|
215
|
+
// Stop the existing user-scope agent so the same identity is not served by
|
|
216
|
+
// two installations. Best-effort: a missing/headless user manager does not
|
|
217
|
+
// block the migration, and rollback re-enables it.
|
|
218
|
+
userSystemctl(['disable', '--now', 'nc-client.service']);
|
|
219
|
+
writeJournal('stop-user-service');
|
|
220
|
+
const envText = sourceEnvText
|
|
221
|
+
.split(/\r?\n/)
|
|
222
|
+
.filter((line) => !MIGRATED_ENV_REMOVALS.test(line))
|
|
223
|
+
.join('\n');
|
|
224
|
+
io.writeFileSync(path.join(targetRoot, 'config', 'nc-client.env'), envText.endsWith('\n') ? envText : envText + '\n', { mode: 0o600 });
|
|
225
|
+
writeJournal('copy:nc-client.env');
|
|
226
|
+
for (const name of PRESERVED_CONFIG_FILES.filter((name) => name !== 'nc-client.env')) {
|
|
227
|
+
const source = path.join(sourceRoot, 'config', name);
|
|
228
|
+
let info;
|
|
229
|
+
try {
|
|
230
|
+
info = io.lstatSync(source);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err?.code === 'ENOENT') continue;
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536) throw new Error(`${name} is not a bounded regular file`);
|
|
236
|
+
const destination = path.join(targetRoot, 'config', name);
|
|
237
|
+
io.copyFileSync(source, destination);
|
|
238
|
+
io.chmodSync(destination, 0o600);
|
|
239
|
+
writeJournal(`copy:${name}`);
|
|
240
|
+
}
|
|
241
|
+
const audit = path.join(sourceRoot, 'logs', 'audit.jsonl');
|
|
242
|
+
try {
|
|
243
|
+
if (io.lstatSync(audit).isFile()) {
|
|
244
|
+
io.copyFileSync(audit, path.join(targetRoot, 'logs', 'audit.jsonl'));
|
|
245
|
+
io.chmodSync(path.join(targetRoot, 'logs', 'audit.jsonl'), 0o600);
|
|
246
|
+
writeJournal('copy:audit.jsonl');
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
250
|
+
}
|
|
251
|
+
writeJournal('complete');
|
|
252
|
+
} catch (err) {
|
|
253
|
+
rollback();
|
|
254
|
+
if (err instanceof PairingError) throw err;
|
|
255
|
+
throw fail('ROOT_ON_MIGRATION_FAILED', `User-to-system migration failed (${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 160)}). The user installation was preserved and the partial system root was rolled back.`);
|
|
256
|
+
}
|
|
257
|
+
return { targetRoot, journalFile, rollback };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* One-time local elevation. Requires an interactive TTY and real root even
|
|
262
|
+
* when already root (the explicit confirmation is the local authorization),
|
|
263
|
+
* rejects unsupported platforms and missing identities with typed errors, and
|
|
264
|
+
* never accepts secrets on the command line.
|
|
265
|
+
* @param {{ hostId: string, platform?: string, input?: import('node:stream').Readable, output?: import('node:stream').Writable, geteuid?: () => number, confirm?: (question: string) => Promise<boolean>, identity?: Record<string, unknown>, targetRoot?: string, deps?: Record<string, Function> }} [options]
|
|
266
|
+
*/
|
|
267
|
+
export async function runRootOn({
|
|
268
|
+
hostId,
|
|
269
|
+
platform = os.platform(),
|
|
270
|
+
input = process.stdin,
|
|
271
|
+
output = process.stdout,
|
|
272
|
+
geteuid = process.geteuid?.bind(process),
|
|
273
|
+
confirm = null,
|
|
274
|
+
identity = null,
|
|
275
|
+
targetRoot = CANONICAL_SYSTEM_ROOT,
|
|
276
|
+
deps = {}
|
|
277
|
+
} = {}) {
|
|
278
|
+
if (!HOST_ID_RE.test(String(hostId || ''))) throw fail('INVALID_HOST_ID', 'The supplied host id is not a canonical nc_<64hex> id.');
|
|
279
|
+
if (platform !== 'linux') {
|
|
280
|
+
throw fail('ROOT_ON_UNSUPPORTED_PLATFORM', 'The privileged helper elevation path is Linux-only. This host remains User-only and does not claim Admin readiness.');
|
|
281
|
+
}
|
|
282
|
+
if (!input?.isTTY || !output?.isTTY) {
|
|
283
|
+
throw fail('ROOT_ON_NON_INTERACTIVE', 'Root-on requires an interactive terminal and explicit local confirmation; no elevation was attempted.');
|
|
284
|
+
}
|
|
285
|
+
if (typeof geteuid !== 'function' || geteuid() !== 0) {
|
|
286
|
+
throw fail('ROOT_ON_NOT_ROOT', 'Root-on requires real root/administrator rights; run it with sudo. No elevation was attempted.');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const inspectTarget = deps.inspectTarget || inspectRootOnTarget;
|
|
290
|
+
const target = inspectTarget({ targetRoot, hostId });
|
|
291
|
+
if (target?.state === 'already-enabled') return { alreadyEnabled: true, hostId, helperReady: true, adminReady: false };
|
|
292
|
+
if (target?.state !== 'absent') {
|
|
293
|
+
throw fail('ROOT_ON_CONFLICT', `Root-on refused: ${target?.reason || 'a conflicting system installation exists'}. Neither installation was changed.`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const activeIdentity = identity || (deps.locateIdentity || locateUserInstallation)({ hostId, platform });
|
|
297
|
+
if (!activeIdentity?.hostToken || !activeIdentity?.privateKey || !activeIdentity?.serverUrl) {
|
|
298
|
+
throw fail('ROOT_ON_IDENTITY_MISSING', 'The existing host identity is incomplete; root-on never re-enrolls. No elevation was attempted.');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const ask = confirm || confirmPrompt;
|
|
302
|
+
const confirmed = await ask(`Elevate host ${sanitizeForDisplay(hostId, 128)} to a system install and bind the privileged helper? [y/N] `);
|
|
303
|
+
if (confirmed !== true) throw fail('ROOT_ON_CONFIRMATION_DECLINED', 'No elevation was performed.');
|
|
304
|
+
|
|
305
|
+
const consume = deps.consumeIntent || consumeRootOnIntent;
|
|
306
|
+
const consumed = await consume({ baseUrl: activeIdentity.serverUrl, hostId, hostToken: activeIdentity.hostToken, privateKey: activeIdentity.privateKey });
|
|
307
|
+
if (!consumed?.intent_id) throw fail('ROOT_ON_INTENT_INVALID', 'No valid root-on intent was returned. No elevation was attempted.');
|
|
308
|
+
|
|
309
|
+
const migrate = deps.migrate || migrateUserInstallation;
|
|
310
|
+
const migrated = await migrate({ sourceRoot: activeIdentity.sourceRoot, targetRoot, hostId, journalFile: deps.journalFile });
|
|
311
|
+
try {
|
|
312
|
+
const install = deps.installAgent || downloadAndInstallAgent;
|
|
313
|
+
const result = await install({
|
|
314
|
+
ncRoot: targetRoot,
|
|
315
|
+
serverUrl: activeIdentity.serverUrl,
|
|
316
|
+
installMode: 'system',
|
|
317
|
+
rootConsent: true,
|
|
318
|
+
hostId,
|
|
319
|
+
hostToken: activeIdentity.hostToken,
|
|
320
|
+
privateKey: activeIdentity.privateKey,
|
|
321
|
+
rootOnIntentId: consumed.intent_id,
|
|
322
|
+
release: consumed.release || undefined
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
hostId,
|
|
326
|
+
helperReady: result?.helperBinding?.bound === true,
|
|
327
|
+
// Admin-ready is claimed only from post-binding control-plane evidence,
|
|
328
|
+
// never from the pre-consumption intent answer (the Owner may have
|
|
329
|
+
// reverted Admin while the local install was running).
|
|
330
|
+
adminReady: result?.helperBinding?.bound === true && result?.helperBinding?.applied_permission === 'admin',
|
|
331
|
+
helperBinding: result?.helperBinding ?? null,
|
|
332
|
+
intentId: consumed.intent_id
|
|
333
|
+
};
|
|
334
|
+
} catch (err) {
|
|
335
|
+
try { migrated?.rollback?.(); } catch {}
|
|
336
|
+
throw err;
|
|
337
|
+
}
|
|
338
|
+
}
|
package/lib/setup.mjs
CHANGED
|
@@ -194,6 +194,31 @@ export function canElevate(options = {}) {
|
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the single local root decision into the final install mode and the
|
|
199
|
+
* elevation requirement. `--user` is always user mode and never asks. An
|
|
200
|
+
* explicit Y without usable elevation is a typed failure: the caller must not
|
|
201
|
+
* silently downgrade to a user install because that would leave the requested
|
|
202
|
+
* Admin helper unprovisioned.
|
|
203
|
+
* @param {{ forceUser?: boolean, explicitOptIn?: boolean, answer?: boolean, platform?: string, elevated?: boolean, elevationAvailable?: boolean }} [options]
|
|
204
|
+
* @returns {{ rootConsent: boolean, installMode: 'system'|'user', elevationRequired: boolean, error: string|null }}
|
|
205
|
+
*/
|
|
206
|
+
export function resolveInstallConsent({ forceUser = false, explicitOptIn = false, answer = false, platform = os.platform(), elevated = false, elevationAvailable = false } = {}) {
|
|
207
|
+
if (forceUser === true) return { rootConsent: false, installMode: 'user', elevationRequired: false, error: null };
|
|
208
|
+
const rootConsent = explicitOptIn === true || answer === true;
|
|
209
|
+
if (!rootConsent) return { rootConsent: false, installMode: 'user', elevationRequired: false, error: null };
|
|
210
|
+
if (elevated === true) return { rootConsent: true, installMode: 'system', elevationRequired: false, error: null };
|
|
211
|
+
// macOS keeps its existing no-automatic-sudo contract; a privileged system
|
|
212
|
+
// install must be started explicitly by an administrator.
|
|
213
|
+
if (platform !== 'linux' && platform !== 'win32') {
|
|
214
|
+
return { rootConsent: true, installMode: 'system', elevationRequired: false, error: 'ROOT_ELEVATION_REQUIRED' };
|
|
215
|
+
}
|
|
216
|
+
if (elevationAvailable !== true) {
|
|
217
|
+
return { rootConsent: true, installMode: 'system', elevationRequired: false, error: 'ROOT_ELEVATION_REQUIRED' };
|
|
218
|
+
}
|
|
219
|
+
return { rootConsent: true, installMode: 'system', elevationRequired: true, error: null };
|
|
220
|
+
}
|
|
221
|
+
|
|
197
222
|
/**
|
|
198
223
|
* Warning text for the given mode: a single line for user mode, null for
|
|
199
224
|
* system mode (callers assert presence/absence on this, not on stdout).
|
|
@@ -494,7 +519,7 @@ const PARTIAL_ENROLLMENT_REQUIRED = ['nc-device-key.pem', 'nc-enroll-state.json'
|
|
|
494
519
|
* @param {string} root
|
|
495
520
|
* @param {typeof fs} [io]
|
|
496
521
|
*/
|
|
497
|
-
function partialEnrollmentSystemRoot(root, io = fs) {
|
|
522
|
+
export function partialEnrollmentSystemRoot(root, io = fs) {
|
|
498
523
|
if (path.resolve(root) !== PARTIAL_SYSTEM_ROOT) return false;
|
|
499
524
|
const lstat = io.lstatSync || fs.lstatSync;
|
|
500
525
|
const readdir = io.readdirSync || fs.readdirSync;
|