node-command 2.0.13 → 2.0.15

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.
Files changed (2) hide show
  1. package/lib/setup.mjs +96 -23
  2. package/package.json +1 -1
package/lib/setup.mjs CHANGED
@@ -707,6 +707,16 @@ const INCOMPLETE_SYSTEM_HOST_ENV = '/opt/nc/config/nc-client.env';
707
707
  const INCOMPLETE_SYSTEM_UNIT = '/etc/systemd/system/nc-client.service';
708
708
  const INCOMPLETE_SYSTEM_UNIT_DROPIN = '/etc/systemd/system/nc-client.service.d';
709
709
  const INCOMPLETE_HELPER_APP = '/opt/nc/libexec/privileged-helper';
710
+ const INCOMPLETE_HELPER_BOOTSTRAP = '/opt/nc/libexec/protected-helper-bootstrap';
711
+ const INCOMPLETE_HELPER_BOOTSTRAP_ENTRIES = new Map([
712
+ ['archive.tar.gz', 'file'],
713
+ ['install-request.json', 'file'],
714
+ // Written by the provisioner from the signed archive before the helper tree
715
+ // is published; installations that failed earlier may not have it yet.
716
+ ['control-plane-public-key.pem', 'optional-file'],
717
+ ['modules', 'directory'],
718
+ ['release', 'directory']
719
+ ]);
710
720
  const INCOMPLETE_HELPER_DAEMON = INCOMPLETE_HELPER_APP + '/privileged-helper-linux-daemon.mjs';
711
721
  const INCOMPLETE_HELPER_PROFILE = INCOMPLETE_HELPER_APP + '/privileged-helper-profile.json';
712
722
  const INCOMPLETE_HELPER_UNIT = '/etc/systemd/system/nc-privileged-helper.service';
@@ -823,12 +833,48 @@ function systemValidateHelperArtifacts(io, runtime) {
823
833
  return { service: !!service, tmpfiles: !!tmpfiles };
824
834
  }
825
835
 
836
+ /**
837
+ * The exact staging tree a protected helper bootstrap writes before
838
+ * provisioning: root-owned regular files/directories, no symlinks, no
839
+ * group/other write bits, and no unknown entry. An absent stage is not a
840
+ * failure by itself; callers that require it check separately.
841
+ */
842
+ function systemValidateStagedHelperBootstrapTree(io) {
843
+ const stage = systemLstatOrNull(io, INCOMPLETE_HELPER_BOOTSTRAP);
844
+ if (!stage) return true;
845
+ if (stage.isSymbolicLink() || !stage.isDirectory() || stage.uid !== 0 || (stage.mode & 0o022)) return false;
846
+ const required = [...INCOMPLETE_HELPER_BOOTSTRAP_ENTRIES].filter(([, type]) => type !== 'optional-file');
847
+ const staged = systemReaddir(io)(INCOMPLETE_HELPER_BOOTSTRAP);
848
+ if (staged.some((name) => !INCOMPLETE_HELPER_BOOTSTRAP_ENTRIES.has(name)) ||
849
+ required.some(([name]) => !staged.includes(name))) return false;
850
+ for (const [name, type] of INCOMPLETE_HELPER_BOOTSTRAP_ENTRIES) {
851
+ if (!staged.includes(name)) continue;
852
+ const info = systemLstatOrNull(io, path.join(INCOMPLETE_HELPER_BOOTSTRAP, name));
853
+ if (!info || info.isSymbolicLink() || info.uid !== 0 || (info.mode & 0o022)) return false;
854
+ if (type === 'directory' ? !info.isDirectory() : (!info.isFile() || info.nlink !== 1)) return false;
855
+ }
856
+ return true;
857
+ }
858
+
859
+ /**
860
+ * `libexec` is removable only while empty or holding that exact staging tree.
861
+ * A provisioned helper, peercred shim or any other content fails closed.
862
+ */
863
+ function systemValidateStagedHelperBootstrap(io, libexec) {
864
+ const entries = systemReaddir(io)(libexec);
865
+ if (entries.length === 0) return true;
866
+ if (entries.length !== 1 || entries[0] !== 'protected-helper-bootstrap') return false;
867
+ return systemValidateStagedHelperBootstrapTree(io);
868
+ }
869
+
826
870
  /**
827
871
  * Recognize the two root-recoverable system-install leftovers this CLI may
828
872
  * remove without the packaged maintenance entry: a helper provisioned but
829
873
  * never bound (`pending`/`not_initialized`, no profile, no lifecycle custody)
830
874
  * and a failed install whose app payload never landed but whose stale unit
831
- * remains. Anything else returns null so callers keep failing closed.
875
+ * remains. A failed install may also leave the exact root-owned protected
876
+ * helper bootstrap staged but never provisioned. Anything else returns null so
877
+ * callers keep failing closed.
832
878
  * @param {string} root
833
879
  * @param {{ platform?: string, io?: typeof fs }} [options]
834
880
  * @returns {null | { kind: 'pending-helper'|'failed-install', runtime: string, payloadUid: number, helperArtifacts: { service: boolean, tmpfiles: boolean } }}
@@ -861,6 +907,11 @@ export function incompleteSystemInstallationShape(root, { platform = os.platform
861
907
  // ordinary root all require their own trusted maintenance.
862
908
  if (systemLstatOrNull(io, INCOMPLETE_HELPER_PROFILE) || systemLstatOrNull(io, INCOMPLETE_SYSTEM_LIFECYCLE) ||
863
909
  systemLstatOrNull(io, INCOMPLETE_SYSTEM_PAIRED_ROOT)) return null;
910
+ // libexec may hold only the helper tree, its peercred shim and the exact
911
+ // pre-provision protected bootstrap stage. Any other entry fails closed.
912
+ const libexecEntries = systemReaddir(io)(path.join(root, 'libexec'));
913
+ if (!libexecEntries.every((name) => ['nc-linux-peercred', 'privileged-helper', 'protected-helper-bootstrap'].includes(name)) ||
914
+ !systemValidateStagedHelperBootstrapTree(io)) return null;
864
915
  const bootstrap = JSON.parse(readSystemFile(io, INCOMPLETE_HELPER_APP + '/helper-bootstrap.json', { mode: 0o600 }));
865
916
  const controlPlaneId = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 &&
866
917
  value.trim() === value && !/[\x00-\x1f\x7f]/.test(value);
@@ -875,20 +926,37 @@ export function incompleteSystemInstallationShape(root, { platform = os.platform
875
926
  bootstrap.ordinary_service_scope !== 'system' ||
876
927
  bootstrap.ack_private_key_file !== INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem' ||
877
928
  !systemCanonical(bootstrap.node_bin)) return null;
878
- readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper.json', { mode: 0o600 });
879
- readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem', { mode: 0o600 });
880
- readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-public.pem', { mode: 0o644 });
881
- const peercred = systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred');
882
- if (!peercred || peercred.isSymbolicLink() || !peercred.isFile() || peercred.uid !== 0 || peercred.nlink !== 1) return null;
883
- const daemon = systemLstatOrNull(io, INCOMPLETE_HELPER_DAEMON);
884
- if (!daemon || daemon.isSymbolicLink() || !daemon.isFile() || daemon.uid !== 0 || !(daemon.mode & 0o111)) return null;
929
+ // The helper tree is either fully prepared but unbound, or only a
930
+ // bootstrap record whose provisioning never completed. The latter is
931
+ // removable only while the exact protected staging tree still exists.
932
+ const helperEntries = systemReaddir(io)(INCOMPLETE_HELPER_APP);
933
+ const bootstrapOnly = helperEntries.length === 1 && helperEntries[0] === 'helper-bootstrap.json';
934
+ let helperArtifacts;
935
+ if (bootstrapOnly) {
936
+ if (!systemLstatOrNull(io, INCOMPLETE_HELPER_BOOTSTRAP) ||
937
+ systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred') ||
938
+ systemLstatOrNull(io, INCOMPLETE_HELPER_UNIT) || systemLstatOrNull(io, INCOMPLETE_HELPER_SOCKET) ||
939
+ systemLstatOrNull(io, INCOMPLETE_HELPER_TMPFILES) || systemLstatOrNull(io, INCOMPLETE_HELPER_UNIT_DROPIN) ||
940
+ systemLstatOrNull(io, INCOMPLETE_HELPER_SOCKET_DROPIN)) return null;
941
+ helperArtifacts = Object.freeze({ service: false, tmpfiles: false });
942
+ } else {
943
+ readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper.json', { mode: 0o600 });
944
+ readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem', { mode: 0o600 });
945
+ readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-public.pem', { mode: 0o644 });
946
+ const peercred = systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred');
947
+ if (!peercred || peercred.isSymbolicLink() || !peercred.isFile() || peercred.uid !== 0 || peercred.nlink !== 1) return null;
948
+ // Node executes the .mjs daemon regardless of its executable bit; the
949
+ // released module is 0644. Require the protected root-owned metadata.
950
+ const daemon = systemLstatOrNull(io, INCOMPLETE_HELPER_DAEMON);
951
+ if (!daemon || daemon.isSymbolicLink() || !daemon.isFile() || daemon.uid !== 0 || (daemon.mode & 0o022)) return null;
952
+ helperArtifacts = systemValidateHelperArtifacts(io, bootstrap.node_bin);
953
+ }
885
954
  readSystemFile(io, INCOMPLETE_SYSTEM_LAUNCHER, { uids: new Set([0, payloadUid]) });
886
955
  const hostEnv = readSystemFile(io, INCOMPLETE_SYSTEM_HOST_ENV, { uids: new Set([0, payloadUid]) });
887
956
  const ids = hostEnv.split(/\r?\n/).filter((line) => /^\s*NC_CLIENT_ID\s*=/.test(line));
888
957
  const clientId = ids[0]?.match(/^\s*NC_CLIENT_ID\s*=\s*(?:"(nc_[0-9a-f]{64})"|'(nc_[0-9a-f]{64})'|(nc_[0-9a-f]{64}))\s*$/);
889
958
  if (ids.length !== 1 || !clientId || (clientId[1] || clientId[2] || clientId[3]) !== bootstrap.client_id) return null;
890
959
  systemValidateClientUnit(io, bootstrap.node_bin);
891
- const helperArtifacts = systemValidateHelperArtifacts(io, bootstrap.node_bin);
892
960
  return Object.freeze({ kind: 'pending-helper', runtime: bootstrap.node_bin, payloadUid, helperArtifacts });
893
961
  }
894
962
  // Failed-install shape: no helper payload, no packaged uninstaller, and an
@@ -900,7 +968,7 @@ export function incompleteSystemInstallationShape(root, { platform = os.platform
900
968
  if (systemLstatOrNull(io, INCOMPLETE_SYSTEM_LIFECYCLE) || systemLstatOrNull(io, INCOMPLETE_SYSTEM_PAIRED_ROOT)) return null;
901
969
  const libexec = systemLstatOrNull(io, path.join(root, 'libexec'));
902
970
  if (libexec) {
903
- if (libexec.isSymbolicLink() || !libexec.isDirectory() || systemReaddir(io)(path.join(root, 'libexec')).length !== 0) return null;
971
+ if (libexec.isSymbolicLink() || !libexec.isDirectory() || !systemValidateStagedHelperBootstrap(io, path.join(root, 'libexec'))) return null;
904
972
  }
905
973
  const launcher = systemLstatOrNull(io, INCOMPLETE_SYSTEM_LAUNCHER);
906
974
  if (launcher && (launcher.isSymbolicLink() || !launcher.isFile() || launcher.size !== 0 ||
@@ -1066,19 +1134,24 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
1066
1134
  let pair = null;
1067
1135
  let incompleteRoot = null;
1068
1136
  if ((rest.platform || os.platform()) === 'linux') {
1069
- let pairedError = null;
1070
- try {
1071
- pair = linuxPairedInstallation(io);
1072
- } catch (err) {
1073
- // Protected metadata this account cannot stat or read is not a local
1074
- // removal target. Any other validation failure remains a hard stop
1075
- // unless the tree is the exact recoverable pending/failed shape.
1076
- if (err?.code !== 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw err;
1077
- pairedError = err;
1078
- }
1079
- if (!pair) {
1080
- if (incompleteSystemInstallationShape(PARTIAL_SYSTEM_ROOT, { platform: 'linux', io })) incompleteRoot = PARTIAL_SYSTEM_ROOT;
1081
- if (pairedError && !incompleteRoot && !pairedMetadataUnreadable(io)) throw pairedError;
1137
+ // A validated recoverable pending/failed shape takes precedence over the
1138
+ // paired classification: the unprovisioned staging tree makes the paired
1139
+ // metadata unverifiable, but the tree is not a paired installation. A
1140
+ // genuinely prepared or bound pair never matches this shape (it requires
1141
+ // lifecycle custody and a profile), so it keeps using the packaged path.
1142
+ if (incompleteSystemInstallationShape(PARTIAL_SYSTEM_ROOT, { platform: 'linux', io })) {
1143
+ incompleteRoot = PARTIAL_SYSTEM_ROOT;
1144
+ } else {
1145
+ let pairedError = null;
1146
+ try {
1147
+ pair = linuxPairedInstallation(io);
1148
+ } catch (err) {
1149
+ // Protected metadata this account cannot stat or read is not a local
1150
+ // removal target. Any other validation failure remains a hard stop.
1151
+ if (err?.code !== 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw err;
1152
+ pairedError = err;
1153
+ }
1154
+ if (!pair && pairedError && !pairedMetadataUnreadable(io)) throw pairedError;
1082
1155
  }
1083
1156
  }
1084
1157
  const env = rest.env || process.env;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.13",
3
+ "version": "2.0.15",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {