permaura 1.3.39 → 1.3.41
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/cli.mjs +4 -34
- package/bin/lib.mjs +82 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
24
24
|
import {
|
|
25
25
|
platformKey,
|
|
26
26
|
binaryFilename,
|
|
27
|
+
shutdownIfProven,
|
|
27
28
|
sweepOldBinaries,
|
|
28
29
|
swapBinaryIntoPlace,
|
|
29
30
|
upgradePlan,
|
|
@@ -489,37 +490,6 @@ async function waitHealthz(addr, tries = 20) {
|
|
|
489
490
|
return false;
|
|
490
491
|
}
|
|
491
492
|
|
|
492
|
-
/**
|
|
493
|
-
* Best-effort graceful stop: `POST /v1/shutdown` (operator-authed, loopback-only) asks the gateway to
|
|
494
|
-
* drain in-flight requests and exit on its own. Resolves true on 202. Falls through to the caller's
|
|
495
|
-
* hard kill on any failure, e.g. a clean (non-seeded) gateway where the demo token isn't valid.
|
|
496
|
-
*/
|
|
497
|
-
function postShutdown(addr, token) {
|
|
498
|
-
return new Promise((resolve) => {
|
|
499
|
-
const [host, port] = addr.split(":");
|
|
500
|
-
const req = http.request(
|
|
501
|
-
{
|
|
502
|
-
method: "POST",
|
|
503
|
-
host,
|
|
504
|
-
port: port || 80,
|
|
505
|
-
path: "/v1/shutdown",
|
|
506
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
507
|
-
timeout: 1500,
|
|
508
|
-
},
|
|
509
|
-
(res) => {
|
|
510
|
-
res.resume();
|
|
511
|
-
resolve(res.statusCode === 202);
|
|
512
|
-
},
|
|
513
|
-
);
|
|
514
|
-
req.on("error", () => resolve(false));
|
|
515
|
-
req.on("timeout", () => {
|
|
516
|
-
req.destroy();
|
|
517
|
-
resolve(false);
|
|
518
|
-
});
|
|
519
|
-
req.end();
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
|
|
523
493
|
async function main() {
|
|
524
494
|
const { cmd, forward, noUpgrade, showVersion, showHelp } = parseArgs(process.argv.slice(2));
|
|
525
495
|
|
|
@@ -623,9 +593,9 @@ async function main() {
|
|
|
623
593
|
// Graceful first: /v1/shutdown lets the gateway drain and exit cleanly. This matters most on
|
|
624
594
|
// Windows, where child.kill() is an abrupt TerminateProcess; on Unix the foreground Ctrl-C
|
|
625
595
|
// already reached the child too, so this is belt-and-braces. The child's exit handler ends the
|
|
626
|
-
// process; the unref'd timer hard-kills if draining wedges. Without a readable token the
|
|
627
|
-
//
|
|
628
|
-
const accepted = token ? await
|
|
596
|
+
// process; the unref'd timer hard-kills if draining wedges. Without a readable token, or when the
|
|
597
|
+
// listener does not prove it holds it, nothing is sent and the signal goes to our own child.
|
|
598
|
+
const accepted = token ? await shutdownIfProven(ADDR, token) : false;
|
|
629
599
|
if (!accepted) child.kill("SIGINT");
|
|
630
600
|
setTimeout(() => {
|
|
631
601
|
child.kill();
|
package/bin/lib.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import crypto from "node:crypto";
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import http from "node:http";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -227,3 +228,84 @@ export function verifyDetachedSignatureAny(rawBytes, sigB64, publicKeyPems) {
|
|
|
227
228
|
export function verifyDetachedSignature(rawBytes, sigB64, publicKeyPem) {
|
|
228
229
|
return verifyDetachedSignatureAny(rawBytes, sigB64, [publicKeyPem]);
|
|
229
230
|
}
|
|
231
|
+
|
|
232
|
+
// ── Proof that the listener holds the operator token (AG-01) ────────────────────────────────────
|
|
233
|
+
//
|
|
234
|
+
// `permaura_core::health_proof` byte for byte: a fresh challenge to `/health` with no bearer, answered with lowercase hex HMAC-SHA256 keyed with the token's UTF-8 bytes over `permaura-health-proof:<port>:<challenge>`; only then is the token sent.
|
|
235
|
+
|
|
236
|
+
export const CHALLENGE_HEADER = "x-permaura-challenge";
|
|
237
|
+
export const PROOF_HEADER = "x-permaura-proof";
|
|
238
|
+
|
|
239
|
+
/** A fresh challenge: 32 random bytes as 64 lowercase hex characters. */
|
|
240
|
+
export function newChallenge() {
|
|
241
|
+
return crypto.randomBytes(32).toString("hex");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The proof a gateway listening on `port` and holding `token` gives for `challenge`, or null. */
|
|
245
|
+
export function healthProof(token, port, challenge) {
|
|
246
|
+
if (!token || typeof challenge !== "string" || !/^[A-Za-z0-9_-]{16,64}$/.test(challenge)) return null;
|
|
247
|
+
return crypto
|
|
248
|
+
.createHmac("sha256", Buffer.from(token, "utf8"))
|
|
249
|
+
.update(`permaura-health-proof:${port}:${challenge}`, "utf8")
|
|
250
|
+
.digest("hex");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Whether `offered` is that proof. Constant-time, exact match only. */
|
|
254
|
+
export function verifyHealthProof(token, port, challenge, offered) {
|
|
255
|
+
const expected = healthProof(token, port, challenge);
|
|
256
|
+
if (!expected || typeof offered !== "string") return false;
|
|
257
|
+
const a = Buffer.from(expected, "utf8");
|
|
258
|
+
const b = Buffer.from(offered.trim(), "utf8");
|
|
259
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** `host:port` (IPv6 hosts in brackets) as the parts `http.request` takes. */
|
|
263
|
+
function splitAddr(addr) {
|
|
264
|
+
const i = addr.lastIndexOf(":");
|
|
265
|
+
return { host: addr.slice(0, i).replace(/^\[|\]$/g, ""), port: Number(addr.slice(i + 1)) || 80 };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One request to `addr`, resolving to the response (body drained) or null on any failure. */
|
|
269
|
+
function request(addr, options, timeoutMs) {
|
|
270
|
+
const { host, port } = splitAddr(addr);
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
const req = http.request({ host, port, timeout: timeoutMs, ...options }, (res) => {
|
|
273
|
+
res.resume();
|
|
274
|
+
resolve(res);
|
|
275
|
+
});
|
|
276
|
+
req.on("error", () => resolve(null));
|
|
277
|
+
req.on("timeout", () => {
|
|
278
|
+
req.destroy();
|
|
279
|
+
resolve(null);
|
|
280
|
+
});
|
|
281
|
+
req.end();
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Whether the listener on `addr` proves it holds `token`, asked with a challenge and nothing else. */
|
|
286
|
+
export async function proveListener(addr, token, timeoutMs = 1500) {
|
|
287
|
+
const challenge = newChallenge();
|
|
288
|
+
const res = await request(
|
|
289
|
+
addr,
|
|
290
|
+
{ method: "GET", path: "/health", headers: { [CHALLENGE_HEADER]: challenge } },
|
|
291
|
+
timeoutMs,
|
|
292
|
+
);
|
|
293
|
+
const offered = res?.headers?.[PROOF_HEADER];
|
|
294
|
+
return verifyHealthProof(token, splitAddr(addr).port, challenge, typeof offered === "string" ? offered : null);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Best-effort graceful stop: `POST /v1/shutdown` (operator-authed, loopback-only) asks the gateway to
|
|
299
|
+
* drain in-flight requests and exit on its own. Resolves true on 202, and false on anything else, the
|
|
300
|
+
* caller then signalling its own child instead. The token goes only to a listener that has just proved
|
|
301
|
+
* it already holds it: whatever holds the port may not be the child at all.
|
|
302
|
+
*/
|
|
303
|
+
export async function shutdownIfProven(addr, token, timeoutMs = 1500) {
|
|
304
|
+
if (!token || !(await proveListener(addr, token, timeoutMs))) return false;
|
|
305
|
+
const res = await request(
|
|
306
|
+
addr,
|
|
307
|
+
{ method: "POST", path: "/v1/shutdown", headers: { Authorization: `Bearer ${token}` } },
|
|
308
|
+
timeoutMs,
|
|
309
|
+
);
|
|
310
|
+
return res?.statusCode === 202;
|
|
311
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "permaura",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.41",
|
|
4
4
|
"description": "Run the Permaura gateway with one command. Give your AI agents permission, not keys. `npm i -g permaura` then `permaura start --pair <code>` installs the gateway, starts it, and connects it to your permaura.com account.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|