querysub 0.662.0 → 0.663.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.662.0",
3
+ "version": "0.663.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -79,7 +79,7 @@
79
79
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
80
80
  "pako": "^2.1.0",
81
81
  "peggy": "^5.0.6",
82
- "sliftutils": "^1.7.132",
82
+ "sliftutils": "^1.7.133",
83
83
  "socket-function": "^1.2.35",
84
84
  "terser": "^5.31.0",
85
85
  "typenode": "^6.6.1",
@@ -140,6 +140,12 @@ export interface ChangeIdentityPayload {
140
140
  debugEntryPoint: string | undefined;
141
141
  callerIsNode: boolean;
142
142
  }
143
+ export interface ChangeIdentityResponse {
144
+ payload: ChangeIdentityPayload;
145
+ signature: string;
146
+ cert: string;
147
+ certIssuer: string;
148
+ }
143
149
  class IdentityControllerBase {
144
150
  // IMPORTANT! We HAVE to call changeIdentity NOT JUST because we can't use peer certificates in the browser, BUT, also
145
151
  // because this removes the need to load peer certificates into the trust store. This greatly simplifies
@@ -147,7 +153,7 @@ class IdentityControllerBase {
147
153
  // if we want to trust a node (instead of having to trust a node before it can connect to us, because
148
154
  // NodeJS will throw/ignore the peer cert if it isn't trusted).
149
155
  @measureFnc
150
- public async changeIdentity(signature: string, payload: ChangeIdentityPayload) {
156
+ public async changeIdentity(signature: string, payload: ChangeIdentityPayload): Promise<ChangeIdentityResponse> {
151
157
  let time = Date.now();
152
158
  const caller = SocketFunction.getCaller();
153
159
  // Verify it wasn't signed too long ago (to make signature stealing WAY more difficult).
@@ -236,6 +242,14 @@ class IdentityControllerBase {
236
242
  clientId: caller.nodeId,
237
243
  });
238
244
  });
245
+
246
+ let ourThreadKeyCert = getThreadKeyCert(getDomain());
247
+ return {
248
+ payload,
249
+ signature: sign(ourThreadKeyCert, payload),
250
+ cert: ourThreadKeyCert.cert.toString(),
251
+ certIssuer: getIdentityCA(getDomain()).cert.toString(),
252
+ };
239
253
  }
240
254
 
241
255
  @measureFnc
@@ -285,11 +299,28 @@ const changeIdentityOnce = cacheWeak(async function changeIdentityOnce(connectio
285
299
  callerIsNode: isServer() && !isIpDomain(nodeId),
286
300
  };
287
301
  let signature = sign(threadKeyCert, payload);
288
- await timeoutToError(
302
+ let response = await timeoutToError(
289
303
  MAX_CHANGE_IDENTITY_TIMEOUT,
290
304
  IdentityController.nodes[nodeId].changeIdentity(signature, payload),
291
305
  () => new Error(`Timeout calling changeIdentity for ${nodeId}`)
292
306
  );
307
+ if (!response) {
308
+ throw new Error(`No identity response from ${nodeId}, so we can't know who we are talking to.`);
309
+ }
310
+ if (JSON.stringify(response.payload) !== JSON.stringify(payload)) {
311
+ throw new Error(`Identity response from ${nodeId} signed a different payload than we sent. Sent ${JSON.stringify(payload)}, they signed ${JSON.stringify(response.payload)}`);
312
+ }
313
+ validateCertificate(getDomain(), response.cert, response.certIssuer);
314
+ verify(response.cert, response.signature, response.payload);
315
+ let nodeObj = decodeNodeId(nodeId, getDomain());
316
+ // If we're not talking to a specific thread, then it likely means there's no machine ID either, ex, it's 127-0-0-1.querysubtest.com. And we chose to talk to this host. The node ID comes from us, So we're not really worried about attackers using this to trick us into thinking there's somebody else.
317
+ if (nodeObj?.threadId) {
318
+ let signerMachineId = getMachineId(getCommonName(response.certIssuer), getDomain());
319
+ let expectedMachineId = nodeObj.machineId;
320
+ if (signerMachineId !== expectedMachineId) {
321
+ throw new Error(`Identity response is from another machine. We connected to ${nodeId} (machine ${expectedMachineId}), but the response was signed by machine ${signerMachineId}`);
322
+ }
323
+ }
293
324
  if (isServer() && !payload.mountedPort) {
294
325
  logErrors((async () => {
295
326
  await SocketFunction.mountPromise;
@@ -14,6 +14,8 @@ import { SERVICE_NAME, SERVICE_UNIT_NAME } from "./machineDaemonShared";
14
14
  Querysub;
15
15
 
16
16
  const pinnedNodeVersion = 22;
17
+ // The Node we target: covers lz4's WASM reference types (externref, default-on from Node 18) with headroom, without going all the way to pinnedNodeVersion on machines that already work. Existing machines below this are upgraded to exactly this.
18
+ const minimumNodeMajor = 20;
17
19
 
18
20
  const DAEMON_SCRIPT_NAME = "machine-daemon.sh";
19
21
  // How long after the process dies before it is started again. Short, because until it is back nothing on the machine is being deployed or repaired.
@@ -120,6 +122,52 @@ async function sshValue(sshRemote: string, command: string, config?: { nothrow?:
120
122
  return (output.trim().split("\n").at(-1) || "").trim();
121
123
  }
122
124
 
125
+ const UNOFFICIAL_BUILDS_URL = "https://unofficial-builds.nodejs.org/download/release";
126
+ // The nodejs project's builds for systems too old for the official binaries (which need glibc 2.28+)
127
+ const UNOFFICIAL_BUILD_FLAVOR = "linux-x64-glibc-217";
128
+ async function installUnofficialNode(sshRemote: string, major: number): Promise<void> {
129
+ let response = await fetch(`${UNOFFICIAL_BUILDS_URL}/index.json`);
130
+ if (!response.ok) {
131
+ throw new Error(`Expected the unofficial builds index at ${UNOFFICIAL_BUILDS_URL}/index.json to be readable, was ${response.status}`);
132
+ }
133
+ let releases = await response.json() as { version: string; files: string[] }[];
134
+ // Newest first, so find gives the latest release of the requested major
135
+ let release = releases.find(release => release.version.startsWith(`v${major}.`) && release.files.includes(UNOFFICIAL_BUILD_FLAVOR));
136
+ if (!release) {
137
+ throw new Error(`Expected a ${UNOFFICIAL_BUILD_FLAVOR} build of Node ${major} at ${UNOFFICIAL_BUILDS_URL}, there is none`);
138
+ }
139
+ let name = `node-${release.version}-${UNOFFICIAL_BUILD_FLAVOR}`;
140
+ console.log(`Installing ${name} to /usr/local/bin (which shadows the distro's node)...`);
141
+ await runPromise(`ssh ${sshRemote} "curl -fsSL ${UNOFFICIAL_BUILDS_URL}/${release.version}/${name}.tar.xz -o /tmp/${name}.tar.xz"`);
142
+ await runPromise(`ssh ${sshRemote} "sudo mkdir -p /usr/local/lib/nodejs && sudo tar -xJf /tmp/${name}.tar.xz -C /usr/local/lib/nodejs && rm /tmp/${name}.tar.xz"`);
143
+ for (let bin of ["node", "npm", "npx"]) {
144
+ await runPromise(`ssh ${sshRemote} "sudo ln -sf /usr/local/lib/nodejs/${name}/bin/${bin} /usr/local/bin/${bin}"`);
145
+ }
146
+ }
147
+
148
+ // How often a pending question is re-printed, as other logging keeps running while we wait and buries it
149
+ const QUESTION_REMIND_INTERVAL = 15 * 1000;
150
+ async function askQuestion(prompt: string): Promise<string> {
151
+ const rl = readline.createInterface({
152
+ // Cast, as different @types/node versions disagree on the stream types
153
+ input: process.stdin as unknown as NodeJS.ReadableStream,
154
+ output: process.stdout,
155
+ });
156
+ let remind = setInterval(() => {
157
+ console.log(`\n(still waiting for an answer)\n${prompt}`);
158
+ }, QUESTION_REMIND_INTERVAL);
159
+ try {
160
+ return await new Promise<string>(resolve => {
161
+ rl.question(prompt, answer => {
162
+ resolve(answer.trim());
163
+ });
164
+ });
165
+ } finally {
166
+ clearInterval(remind);
167
+ rl.close();
168
+ }
169
+ }
170
+
123
171
  function getGitHubKeyCachePath(repoUrl: string): string {
124
172
  let repoOwner = "";
125
173
  let repoName = "";
@@ -199,19 +247,7 @@ async function getGitHubApiKey(repoUrl: string, sshRemote: string, forceRefresh
199
247
 
200
248
  await open(tokenUrl);
201
249
 
202
- const rl = readline.createInterface({
203
- // Cast, as different @types/node versions disagree on the stream types
204
- input: process.stdin as unknown as NodeJS.ReadableStream,
205
- output: process.stdout
206
- });
207
-
208
- const apiKey = await new Promise<string>((resolve) => {
209
- const repoInfo = repoOwner && repoName ? ` for repository ${repoOwner}/${repoName}` : "";
210
- rl.question(`Please paste your GitHub API token (${repoInfo}): `, (answer) => {
211
- rl.close();
212
- resolve(answer.trim());
213
- });
214
- });
250
+ const apiKey = await askQuestion(`Please paste your GitHub API token (${repoOwner && repoName && `for repository ${repoOwner}/${repoName}` || ""}): `);
215
251
 
216
252
  // Cache the key
217
253
  fs.writeFileSync(cacheFile, JSON.stringify({ apiKey }));
@@ -453,8 +489,32 @@ async function main() {
453
489
  // 3. Ensure nodejs is installed on remote server
454
490
  console.log("Ensuring Node.js is installed...");
455
491
  try {
456
- let nodeVersion = await runPromise(`ssh ${sshRemote} "node --version"`);
457
- console.log("✅ Node.js already installed:", nodeVersion.trim());
492
+ let nodeVersion = await sshValue(sshRemote, "node --version");
493
+ console.log("✅ Node.js already installed:", nodeVersion);
494
+ let nodeMajor = parseInt(nodeVersion.replace(/^v/, "").split(".")[0]);
495
+ if (Number.isFinite(nodeMajor) && nodeMajor < minimumNodeMajor) {
496
+ let answer = await askQuestion(`Node ${nodeVersion} on ${sshRemote} is too old (our code targets Node ${minimumNodeMajor}, and needs at least 18 for WASM reference types). Upgrade it to Node ${minimumNodeMajor} - and no further, so nothing else on the machine breaks? [y/N] `);
497
+ if (answer.trim().toLowerCase().startsWith("y")) {
498
+ console.log(`Upgrading Node.js to v${minimumNodeMajor}...`);
499
+ await runPromise(`ssh ${sshRemote} "curl -fsSL https://deb.nodesource.com/setup_${minimumNodeMajor}.x | sudo -E bash -"`);
500
+ await runPromise(`ssh ${sshRemote} "sudo apt-get install -y nodejs"`);
501
+ let upgradedVersion = await sshValue(sshRemote, "node --version");
502
+ let upgradedMajor = parseInt(upgradedVersion.replace(/^v/, "").split(".")[0]);
503
+ if (!Number.isFinite(upgradedMajor) || upgradedMajor < minimumNodeMajor) {
504
+ // nodesource silently keeps the old repo when it has no build for this distro (its Node 18+ debs need glibc 2.28+), so old machines get the unofficial glibc-2.17 build instead
505
+ console.warn(`⚠️ Node is still ${upgradedVersion} after the nodesource upgrade, so this distro is too old for nodesource. Installing the unofficial glibc-2.17 build of Node ${minimumNodeMajor} into /usr/local/bin instead...`);
506
+ await installUnofficialNode(sshRemote, minimumNodeMajor);
507
+ upgradedVersion = await sshValue(sshRemote, "node --version");
508
+ upgradedMajor = parseInt(upgradedVersion.replace(/^v/, "").split(".")[0]);
509
+ if (!Number.isFinite(upgradedMajor) || upgradedMajor < minimumNodeMajor) {
510
+ throw new Error(`Node is still ${upgradedVersion} after installing the unofficial build. Check what "which -a node" resolves to on ${sshRemote} - something earlier on the PATH is shadowing /usr/local/bin.`);
511
+ }
512
+ }
513
+ console.log("✅ Node.js upgraded:", upgradedVersion);
514
+ } else {
515
+ console.warn(`⚠️ Keeping Node ${nodeVersion}. The machine process will likely crash with "invalid value type 'externref'" until Node is upgraded.`);
516
+ }
517
+ }
458
518
  } catch {
459
519
  console.log(`Installing Node.js (v${pinnedNodeVersion})...`);
460
520
  await runPromise(`ssh ${sshRemote} "curl -fsSL https://deb.nodesource.com/setup_${pinnedNodeVersion}.x | sudo -E bash -"`);