@umbra-privacy/qos-ceremony 0.1.1 → 0.1.3
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/dist-cli/cli.js +118 -28
- package/package.json +2 -2
package/dist-cli/cli.js
CHANGED
|
@@ -31567,7 +31567,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
31567
31567
|
};
|
|
31568
31568
|
var DescribeTargetGroupsCommand2 = class extends command5(_ep05, _mw05, "DescribeTargetGroups", DescribeTargetGroups$) {
|
|
31569
31569
|
};
|
|
31570
|
-
var
|
|
31570
|
+
var DescribeTargetHealthCommand2 = class extends command5(_ep05, _mw05, "DescribeTargetHealth", DescribeTargetHealth$) {
|
|
31571
31571
|
};
|
|
31572
31572
|
var DescribeTrustStoreAssociationsCommand = class extends command5(_ep05, _mw05, "DescribeTrustStoreAssociations", DescribeTrustStoreAssociations$) {
|
|
31573
31573
|
};
|
|
@@ -31743,7 +31743,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
31743
31743
|
var checkState$1 = async (client, input) => {
|
|
31744
31744
|
let reason;
|
|
31745
31745
|
try {
|
|
31746
|
-
let result = await client.send(new
|
|
31746
|
+
let result = await client.send(new DescribeTargetHealthCommand2(input));
|
|
31747
31747
|
reason = result;
|
|
31748
31748
|
try {
|
|
31749
31749
|
const returnComparator = () => {
|
|
@@ -31782,7 +31782,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
31782
31782
|
var checkState = async (client, input) => {
|
|
31783
31783
|
let reason;
|
|
31784
31784
|
try {
|
|
31785
|
-
let result = await client.send(new
|
|
31785
|
+
let result = await client.send(new DescribeTargetHealthCommand2(input));
|
|
31786
31786
|
reason = result;
|
|
31787
31787
|
try {
|
|
31788
31788
|
const returnComparator = () => {
|
|
@@ -31846,7 +31846,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
31846
31846
|
DescribeTagsCommand: DescribeTagsCommand2,
|
|
31847
31847
|
DescribeTargetGroupAttributesCommand,
|
|
31848
31848
|
DescribeTargetGroupsCommand: DescribeTargetGroupsCommand2,
|
|
31849
|
-
DescribeTargetHealthCommand,
|
|
31849
|
+
DescribeTargetHealthCommand: DescribeTargetHealthCommand2,
|
|
31850
31850
|
DescribeTrustStoreAssociationsCommand,
|
|
31851
31851
|
DescribeTrustStoreRevocationsCommand,
|
|
31852
31852
|
DescribeTrustStoresCommand,
|
|
@@ -32185,7 +32185,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
32185
32185
|
exports2.DescribeTargetGroupsInput$ = DescribeTargetGroupsInput$;
|
|
32186
32186
|
exports2.DescribeTargetGroupsOutput$ = DescribeTargetGroupsOutput$;
|
|
32187
32187
|
exports2.DescribeTargetHealth$ = DescribeTargetHealth$;
|
|
32188
|
-
exports2.DescribeTargetHealthCommand =
|
|
32188
|
+
exports2.DescribeTargetHealthCommand = DescribeTargetHealthCommand2;
|
|
32189
32189
|
exports2.DescribeTargetHealthInput$ = DescribeTargetHealthInput$;
|
|
32190
32190
|
exports2.DescribeTargetHealthInputIncludeEnum = DescribeTargetHealthInputIncludeEnum;
|
|
32191
32191
|
exports2.DescribeTargetHealthOutput$ = DescribeTargetHealthOutput$;
|
|
@@ -32823,10 +32823,24 @@ var Workdir = class {
|
|
|
32823
32823
|
};
|
|
32824
32824
|
|
|
32825
32825
|
// ../qos-core/src/api.ts
|
|
32826
|
+
function enforceHttps(url) {
|
|
32827
|
+
if (!url) throw new Error("coordinator base URL is required");
|
|
32828
|
+
let u;
|
|
32829
|
+
try {
|
|
32830
|
+
u = new URL(url);
|
|
32831
|
+
} catch {
|
|
32832
|
+
throw new Error(`invalid coordinator base URL: ${url}`);
|
|
32833
|
+
}
|
|
32834
|
+
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
|
32835
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocal)) {
|
|
32836
|
+
throw new Error(`coordinator base URL must use https (got ${u.protocol}//${u.hostname})`);
|
|
32837
|
+
}
|
|
32838
|
+
return url;
|
|
32839
|
+
}
|
|
32826
32840
|
var CeremonyApi = class {
|
|
32827
32841
|
constructor(baseUrl) {
|
|
32828
32842
|
this.baseUrl = baseUrl;
|
|
32829
|
-
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
32843
|
+
this.baseUrl = enforceHttps(baseUrl.replace(/\/$/, ""));
|
|
32830
32844
|
}
|
|
32831
32845
|
base(ns, nonce) {
|
|
32832
32846
|
return `${this.baseUrl}/ceremony/${encodeURIComponent(ns)}/${nonce}`;
|
|
@@ -32843,6 +32857,13 @@ var CeremonyApi = class {
|
|
|
32843
32857
|
if (!r5.ok) throw new Error(`getArtifact ${path}: ${r5.status} ${await r5.text()}`);
|
|
32844
32858
|
return Buffer.from(await r5.text(), "base64");
|
|
32845
32859
|
}
|
|
32860
|
+
/** Fetch a namespace-shared artifact (`quorum_key.pub`, `shares/<alias>.share`) — nonce-free,
|
|
32861
|
+
* invariant across a service's colors. Same base64 encoding as getArtifact. */
|
|
32862
|
+
async getSharedArtifact(ns, path) {
|
|
32863
|
+
const r5 = await fetch(`${this.baseUrl}/shared/${encodeURIComponent(ns)}/${path}`);
|
|
32864
|
+
if (!r5.ok) throw new Error(`getSharedArtifact ${path}: ${r5.status} ${await r5.text()}`);
|
|
32865
|
+
return Buffer.from(await r5.text(), "base64");
|
|
32866
|
+
}
|
|
32846
32867
|
/** Registered operator pubkeys (alias → hex) for the ceremony's service. The
|
|
32847
32868
|
* registry is the source of member pubkeys (no per-ceremony upload). */
|
|
32848
32869
|
async getMembers(ns, nonce) {
|
|
@@ -32966,9 +32987,19 @@ function resolveQosBin(explicit) {
|
|
|
32966
32987
|
if (explicit) return explicit;
|
|
32967
32988
|
if (process.env.QOS_CLIENT_BIN) return process.env.QOS_CLIENT_BIN;
|
|
32968
32989
|
const embedded = (0, import_node_path2.join)(__dirname, "bin", `qos_client-${process.platform}-${process.arch}`);
|
|
32969
|
-
if ((0, import_node_fs2.existsSync)(embedded))
|
|
32990
|
+
if ((0, import_node_fs2.existsSync)(embedded)) {
|
|
32991
|
+
ensureExecutable(embedded);
|
|
32992
|
+
return embedded;
|
|
32993
|
+
}
|
|
32970
32994
|
return void 0;
|
|
32971
32995
|
}
|
|
32996
|
+
function ensureExecutable(path) {
|
|
32997
|
+
try {
|
|
32998
|
+
const mode = (0, import_node_fs2.statSync)(path).mode;
|
|
32999
|
+
if ((mode & 73) !== 73) (0, import_node_fs2.chmodSync)(path, mode | 493);
|
|
33000
|
+
} catch {
|
|
33001
|
+
}
|
|
33002
|
+
}
|
|
32972
33003
|
|
|
32973
33004
|
// src/commands/keygen.ts
|
|
32974
33005
|
async function keygen(o3) {
|
|
@@ -33031,15 +33062,28 @@ function resolve(o3) {
|
|
|
33031
33062
|
|
|
33032
33063
|
// src/artifacts.ts
|
|
33033
33064
|
var import_node_fs4 = require("node:fs");
|
|
33065
|
+
var SAFE_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
33066
|
+
function assertSafeAlias(alias) {
|
|
33067
|
+
if (!SAFE_ALIAS.test(alias)) {
|
|
33068
|
+
throw new Error(
|
|
33069
|
+
`refusing coordinator-supplied member alias '${alias}' \u2014 not a safe filename (expected ${SAFE_ALIAS}); a malicious coordinator must not drive a local path`
|
|
33070
|
+
);
|
|
33071
|
+
}
|
|
33072
|
+
}
|
|
33034
33073
|
async function pullArtifact(ctx, wd, apiPath, localRel) {
|
|
33035
33074
|
const bytes = await ctx.api.getArtifact(ctx.namespace, ctx.nonce, apiPath);
|
|
33036
33075
|
return wd.writeBytes(localRel, bytes);
|
|
33037
33076
|
}
|
|
33077
|
+
async function pullShared(ctx, wd, sharedPath, localRel) {
|
|
33078
|
+
const bytes = await ctx.api.getSharedArtifact(ctx.namespace, sharedPath);
|
|
33079
|
+
return wd.writeBytes(localRel, bytes);
|
|
33080
|
+
}
|
|
33038
33081
|
async function buildSetDirs(ctx, wd, sets) {
|
|
33039
33082
|
const members = await ctx.api.getMembers(ctx.namespace, ctx.nonce);
|
|
33040
33083
|
const build = (name, set) => {
|
|
33041
33084
|
const dir = wd.dir(name);
|
|
33042
33085
|
for (const alias of set.members) {
|
|
33086
|
+
assertSafeAlias(alias);
|
|
33043
33087
|
const hex = members[alias];
|
|
33044
33088
|
if (!hex)
|
|
33045
33089
|
throw new Error(`operator '${alias}' has no registered pubkey (admin register-operator)`);
|
|
@@ -33057,7 +33101,7 @@ async function buildSetDirs(ctx, wd, sets) {
|
|
|
33057
33101
|
async function pullGenesisDir(ctx, wd) {
|
|
33058
33102
|
const dir = wd.dir("namespace");
|
|
33059
33103
|
await pullArtifact(ctx, wd, "genesis/genesis_output", "namespace/genesis_output");
|
|
33060
|
-
await
|
|
33104
|
+
await pullShared(ctx, wd, "quorum_key.pub", "namespace/quorum_key.pub").catch(() => "");
|
|
33061
33105
|
await pullArtifact(
|
|
33062
33106
|
ctx,
|
|
33063
33107
|
wd,
|
|
@@ -33107,7 +33151,7 @@ async function approve(o3) {
|
|
|
33107
33151
|
try {
|
|
33108
33152
|
const manifestPath = await pullArtifact(ctx, wd, "manifest/manifest", "manifest");
|
|
33109
33153
|
const pivotHashPath = await pullArtifact(ctx, wd, "manifest/pivot.hash", "pivot.hash");
|
|
33110
|
-
const quorumKeyPath = await
|
|
33154
|
+
const quorumKeyPath = await pullShared(ctx, wd, "quorum_key.pub", "quorum_key.pub");
|
|
33111
33155
|
const { manifestSetDir, shareSetDir, patchSetDir } = await buildSetDirs(ctx, wd, state2.sets);
|
|
33112
33156
|
const approvalsDir = wd.dir("approvals");
|
|
33113
33157
|
const json = ctx.qos.display(manifestPath, "manifest");
|
|
@@ -33175,7 +33219,7 @@ async function provision(o3) {
|
|
|
33175
33219
|
"manifest/manifest_envelope",
|
|
33176
33220
|
"manifest_envelope"
|
|
33177
33221
|
);
|
|
33178
|
-
const sharePath = await
|
|
33222
|
+
const sharePath = await pullShared(ctx, wd, `shares/${ctx.alias}.share`, `${ctx.alias}.share`);
|
|
33179
33223
|
const { manifestSetDir } = await buildSetDirs(ctx, wd, state2.sets);
|
|
33180
33224
|
const ephWrappedSharePath = wd.path(`${ctx.alias}.eph_share`);
|
|
33181
33225
|
const approvalPath = wd.path(`${ctx.alias}.attestation.approval`);
|
|
@@ -33237,14 +33281,14 @@ function buildWeightedForward(tgs, weights) {
|
|
|
33237
33281
|
}
|
|
33238
33282
|
}
|
|
33239
33283
|
if (tgs.length === 0) throw new Error("shift-traffic: no target groups to forward to");
|
|
33240
|
-
|
|
33241
|
-
|
|
33242
|
-
|
|
33243
|
-
|
|
33244
|
-
|
|
33245
|
-
|
|
33246
|
-
|
|
33247
|
-
];
|
|
33284
|
+
const resolved = tgs.map((t) => ({ TargetGroupArn: t.arn, Weight: weights[t.nonce] ?? 0 }));
|
|
33285
|
+
const total = resolved.reduce((s, t) => s + (t.Weight ?? 0), 0);
|
|
33286
|
+
if (total <= 0) {
|
|
33287
|
+
throw new Error(
|
|
33288
|
+
"shift-traffic: refusing \u2014 the resulting target-group weights sum to 0, which black-holes ALL client traffic (503). Give at least one color a positive weight."
|
|
33289
|
+
);
|
|
33290
|
+
}
|
|
33291
|
+
return [{ Type: "forward", ForwardConfig: { TargetGroups: resolved } }];
|
|
33248
33292
|
}
|
|
33249
33293
|
async function discoverColorTargetGroups(elbv2, service, stack, role) {
|
|
33250
33294
|
const all = [];
|
|
@@ -33286,13 +33330,22 @@ async function discoverColorTargetGroups(elbv2, service, stack, role) {
|
|
|
33286
33330
|
}
|
|
33287
33331
|
async function findListenerForTargetGroups(elbv2, lbArn, tgArns) {
|
|
33288
33332
|
const r5 = await elbv2.send(new import_client_elastic_load_balancing_v2.DescribeListenersCommand({ LoadBalancerArn: lbArn }));
|
|
33333
|
+
const matches = [];
|
|
33289
33334
|
for (const l3 of r5.Listeners ?? []) {
|
|
33290
33335
|
const forwards = (l3.DefaultActions ?? []).flatMap((a5) => a5.ForwardConfig?.TargetGroups ?? []);
|
|
33291
|
-
if (forwards.some((t) => t.TargetGroupArn && tgArns.has(t.TargetGroupArn))) {
|
|
33292
|
-
|
|
33336
|
+
if (forwards.some((t) => t.TargetGroupArn && tgArns.has(t.TargetGroupArn)) && l3.ListenerArn) {
|
|
33337
|
+
matches.push(l3.ListenerArn);
|
|
33293
33338
|
}
|
|
33294
33339
|
}
|
|
33295
|
-
|
|
33340
|
+
if (matches.length === 0) {
|
|
33341
|
+
throw new Error("shift-traffic: no listener forwards to the discovered target groups");
|
|
33342
|
+
}
|
|
33343
|
+
if (matches.length > 1) {
|
|
33344
|
+
throw new Error(
|
|
33345
|
+
`shift-traffic: ${matches.length} listeners forward to these target groups \u2014 refusing to guess which to modify`
|
|
33346
|
+
);
|
|
33347
|
+
}
|
|
33348
|
+
return matches[0];
|
|
33296
33349
|
}
|
|
33297
33350
|
async function shiftTraffic(opts) {
|
|
33298
33351
|
const role = opts.listener ?? "app";
|
|
@@ -33304,8 +33357,25 @@ async function shiftTraffic(opts) {
|
|
|
33304
33357
|
new Set(tgs.map((t) => t.arn))
|
|
33305
33358
|
);
|
|
33306
33359
|
const actions = buildWeightedForward(tgs, opts.weights);
|
|
33360
|
+
if (opts.dryRun) return { listenerArn, actions, applied: false };
|
|
33361
|
+
if (!opts.force) {
|
|
33362
|
+
const noHealthy = [];
|
|
33363
|
+
for (const t of tgs) {
|
|
33364
|
+
if ((opts.weights[t.nonce] ?? 0) <= 0) continue;
|
|
33365
|
+
const h5 = await elbv2.send(new import_client_elastic_load_balancing_v2.DescribeTargetHealthCommand({ TargetGroupArn: t.arn }));
|
|
33366
|
+
const healthy = (h5.TargetHealthDescriptions ?? []).filter(
|
|
33367
|
+
(d5) => d5.TargetHealth?.State === "healthy"
|
|
33368
|
+
).length;
|
|
33369
|
+
if (healthy === 0) noHealthy.push(t.nonce);
|
|
33370
|
+
}
|
|
33371
|
+
if (noHealthy.length > 0) {
|
|
33372
|
+
throw new Error(
|
|
33373
|
+
`shift-traffic: refusing cutover \u2014 color(s) [${noHealthy.join(", ")}] have 0 healthy targets; routing traffic to them would 503 every client. Wait for health, or pass --force.`
|
|
33374
|
+
);
|
|
33375
|
+
}
|
|
33376
|
+
}
|
|
33307
33377
|
await elbv2.send(new import_client_elastic_load_balancing_v2.ModifyListenerCommand({ ListenerArn: listenerArn, DefaultActions: actions }));
|
|
33308
|
-
return { listenerArn, actions };
|
|
33378
|
+
return { listenerArn, actions, applied: true };
|
|
33309
33379
|
}
|
|
33310
33380
|
async function shiftTrafficCommand(o3) {
|
|
33311
33381
|
const service = o3.service ?? process.env.CEREMONY_SERVICE;
|
|
@@ -33316,16 +33386,36 @@ async function shiftTrafficCommand(o3) {
|
|
|
33316
33386
|
const weights = {};
|
|
33317
33387
|
for (const [k5, v] of Object.entries(parsed)) weights[Number(k5)] = v;
|
|
33318
33388
|
const listener = o3.listener ?? "app";
|
|
33319
|
-
const
|
|
33389
|
+
const plan = await shiftTraffic({
|
|
33390
|
+
service,
|
|
33391
|
+
stack,
|
|
33392
|
+
region: o3.region,
|
|
33393
|
+
listener,
|
|
33394
|
+
weights,
|
|
33395
|
+
dryRun: true
|
|
33396
|
+
});
|
|
33397
|
+
const summary = (plan.actions[0].ForwardConfig?.TargetGroups ?? []).map((t) => `${t.TargetGroupArn?.split("/").slice(-2, -1)[0] ?? t.TargetGroupArn}=${t.Weight}`).join(" ");
|
|
33398
|
+
console.log(`shift-traffic PLAN: ${service}/${stack} ${listener} listener \u2192 ${summary}`);
|
|
33399
|
+
console.log(` listener: ${plan.listenerArn}`);
|
|
33400
|
+
if (o3.dryRun) {
|
|
33401
|
+
console.log(" (dry-run \u2014 no change applied)");
|
|
33402
|
+
return;
|
|
33403
|
+
}
|
|
33404
|
+
if (!o3.yes) {
|
|
33405
|
+
throw new Error(
|
|
33406
|
+
"shift-traffic: refusing to apply a live traffic cutover without --yes (review the PLAN above, then re-run with --yes; or use --dry-run)"
|
|
33407
|
+
);
|
|
33408
|
+
}
|
|
33409
|
+
const applied = await shiftTraffic({
|
|
33320
33410
|
service,
|
|
33321
33411
|
stack,
|
|
33322
33412
|
region: o3.region,
|
|
33323
33413
|
listener,
|
|
33324
|
-
weights
|
|
33414
|
+
weights,
|
|
33415
|
+
force: o3.force
|
|
33325
33416
|
});
|
|
33326
|
-
|
|
33327
|
-
console.log(`
|
|
33328
|
-
console.log(` listener: ${listenerArn}`);
|
|
33417
|
+
console.log(`shift-traffic APPLIED \u2192 ${summary}`);
|
|
33418
|
+
console.log(` listener: ${applied.listenerArn}`);
|
|
33329
33419
|
}
|
|
33330
33420
|
|
|
33331
33421
|
// src/cli.ts
|
|
@@ -33360,7 +33450,7 @@ withCommon(
|
|
|
33360
33450
|
program2.command("status").description("show ceremony phase + blockers (read-only)").option("--api <url>", "coordinator API base URL (env CEREMONY_API)").option("--namespace <ns>", "ceremony namespace (env CEREMONY_NAMESPACE)").option("--nonce <n>", "manifest nonce (env CEREMONY_NONCE)").action(run(status));
|
|
33361
33451
|
program2.command("shift-traffic").description(
|
|
33362
33452
|
"[ops] blue-green weighted traffic cutover across a service's colors (needs AWS creds)"
|
|
33363
|
-
).option("--service <name>", "service name (env CEREMONY_SERVICE)").option("--stack <stack>", "stack (env CEREMONY_STACK)").option("--region <r>", "AWS region").option("--listener <app|control>", "which listener carries the split (default app)").option("--weights <json>", `nonce->weight map, e.g. '{"2026070601":0,"2026071501":100}'`).action(run(shiftTrafficCommand));
|
|
33453
|
+
).option("--service <name>", "service name (env CEREMONY_SERVICE)").option("--stack <stack>", "stack (env CEREMONY_STACK)").option("--region <r>", "AWS region").option("--listener <app|control>", "which listener carries the split (default app)").option("--weights <json>", `nonce->weight map, e.g. '{"2026070601":0,"2026071501":100}'`).option("--dry-run", "print the planned weighted forward and exit without applying").option("--yes", "confirm applying the live traffic cutover (required unless --dry-run)").option("--force", "skip the target-health precheck (allow shifting to a 0-healthy color)").action(run(shiftTrafficCommand));
|
|
33364
33454
|
program2.parseAsync(process.argv).catch((e5) => {
|
|
33365
33455
|
console.error(e5);
|
|
33366
33456
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umbra-privacy/qos-ceremony",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "QOS TEE operator ceremony CLI — keygen/share/approve/provision + shift-traffic over the coordinator API. All crypto runs in qos_client (embedded for common platforms); your private key never leaves the device. Ships NO coordinator code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"qos",
|
|
@@ -44,6 +44,6 @@
|
|
|
44
44
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
45
|
"test": "vitest run",
|
|
46
46
|
"bundle:cli": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist-cli/cli.js && chmod +x dist-cli/cli.js",
|
|
47
|
-
"embed:qos": "mkdir -p dist-cli/bin && ( [ -f ../../../qos/target/release/qos_client ] && cp -L ../../../qos/target/release/qos_client dist-cli/bin/qos_client-darwin-arm64 || true ) && ( [ -f ../ceremony/dist-lambda/qos_client ] && cp -L ../ceremony/dist-lambda/qos_client dist-cli/bin/qos_client-linux-x64 || true ) && ls -la dist-cli/bin"
|
|
47
|
+
"embed:qos": "mkdir -p dist-cli/bin && ( [ -f ../../../qos/target/release/qos_client ] && cp -L ../../../qos/target/release/qos_client dist-cli/bin/qos_client-darwin-arm64 || true ) && ( [ -f ../ceremony/dist-lambda/qos_client ] && cp -L ../ceremony/dist-lambda/qos_client dist-cli/bin/qos_client-linux-x64 || true ) && ( chmod +x dist-cli/bin/* 2>/dev/null || true ) && ls -la dist-cli/bin"
|
|
48
48
|
}
|
|
49
49
|
}
|