@calltelemetry/ct-lab-mcp 0.8.105 → 0.8.107

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 (63) hide show
  1. package/assets/stig/SHA256SUMS +15 -1
  2. package/assets/stig/guest/collect-fips-state.sh +6 -0
  3. package/assets/stig/guest/collect-hardware.sh +3 -0
  4. package/assets/stig/guest/collect-host-checks.sh +3 -0
  5. package/assets/stig/guest/collect-network-services.sh +15 -0
  6. package/assets/stig/guest/collect-repolist.sh +3 -0
  7. package/assets/stig/guest/collect-rpm-inventory.sh +3 -0
  8. package/assets/stig/guest/collect-rpm-nevra.sh +3 -0
  9. package/assets/stig/guest/collect-rpmlist.sh +3 -0
  10. package/assets/stig/guest/collect-updateinfo-all.sh +3 -0
  11. package/assets/stig/guest/collect-updateinfo-security.sh +3 -0
  12. package/assets/stig/guest/collect-updateinfo-summary.sh +3 -0
  13. package/assets/stig/guest/oval-eval.sh +11 -0
  14. package/assets/stig/guest/oval-prep.sh +4 -0
  15. package/assets/stig/guest/verify-root.sh +13 -0
  16. package/assets/stig/image/verify-image.sh +28 -0
  17. package/dist/src/generated-version.d.ts +1 -1
  18. package/dist/src/generated-version.js +1 -1
  19. package/dist/src/stig/dist.d.ts +121 -0
  20. package/dist/src/stig/dist.d.ts.map +1 -0
  21. package/dist/src/stig/dist.js +234 -0
  22. package/dist/src/stig/dist.js.map +1 -0
  23. package/dist/src/stig/distribution.d.ts +95 -0
  24. package/dist/src/stig/distribution.d.ts.map +1 -0
  25. package/dist/src/stig/distribution.js +398 -0
  26. package/dist/src/stig/distribution.js.map +1 -0
  27. package/dist/src/stig/evidence.d.ts +135 -0
  28. package/dist/src/stig/evidence.d.ts.map +1 -0
  29. package/dist/src/stig/evidence.js +637 -0
  30. package/dist/src/stig/evidence.js.map +1 -0
  31. package/dist/src/stig/live.d.ts +67 -4
  32. package/dist/src/stig/live.d.ts.map +1 -1
  33. package/dist/src/stig/live.js +226 -5
  34. package/dist/src/stig/live.js.map +1 -1
  35. package/dist/src/stig/markers.d.ts +6 -0
  36. package/dist/src/stig/markers.d.ts.map +1 -0
  37. package/dist/src/stig/markers.js +23 -0
  38. package/dist/src/stig/markers.js.map +1 -0
  39. package/dist/src/stig/model.d.ts +2 -1
  40. package/dist/src/stig/model.d.ts.map +1 -1
  41. package/dist/src/stig/model.js +7 -2
  42. package/dist/src/stig/model.js.map +1 -1
  43. package/dist/src/stig/nodes.d.ts +52 -12
  44. package/dist/src/stig/nodes.d.ts.map +1 -1
  45. package/dist/src/stig/nodes.js +90 -11
  46. package/dist/src/stig/nodes.js.map +1 -1
  47. package/dist/src/stig/ports.d.ts +81 -2
  48. package/dist/src/stig/ports.d.ts.map +1 -1
  49. package/dist/src/stig/pve.d.ts +22 -0
  50. package/dist/src/stig/pve.d.ts.map +1 -0
  51. package/dist/src/stig/pve.js +26 -0
  52. package/dist/src/stig/pve.js.map +1 -0
  53. package/dist/src/stig/runner.d.ts +36 -9
  54. package/dist/src/stig/runner.d.ts.map +1 -1
  55. package/dist/src/stig/runner.js +129 -96
  56. package/dist/src/stig/runner.js.map +1 -1
  57. package/dist/src/tools/quick-provision.d.ts +2 -2
  58. package/dist/src/tools/slot-tools.d.ts +4 -4
  59. package/dist/src/tools/stig-tools.d.ts +52 -8
  60. package/dist/src/tools/stig-tools.d.ts.map +1 -1
  61. package/dist/src/tools/stig-tools.js +105 -21
  62. package/dist/src/tools/stig-tools.js.map +1 -1
  63. package/package.json +1 -1
@@ -1,20 +1,20 @@
1
1
  import { type StigCounts, type StigDescriptor, type StigReceiptSummary, stigStatusPreview } from "./model.js";
2
- import { type StigNode, type StigNodeConfig } from "./nodes.js";
3
2
  import type { ClaimRecord, StigDeps } from "./ports.js";
3
+ import { type StigNode, type StigNodeName } from "./nodes.js";
4
+ import { VMID_RANGE } from "./pve.js";
5
+ export declare const STIG_NODE_ANNOTATION = "calltelemetry.com/stig-node";
6
+ export { VMID_RANGE };
4
7
  export declare const BUILD_CLAIM_TTL_HOURS = 6;
5
8
  export declare const DEFAULT_CLONE_TTL_HOURS = 48;
6
- export declare const VMID_RANGE: {
7
- readonly min: 8100;
8
- readonly max: 8899;
9
- };
9
+ export declare const dopplerKeyFor: (cloneName: string) => string;
10
10
  export interface ClonePlan {
11
11
  mode: "plan";
12
- node: StigNode;
13
12
  recreate?: {
14
13
  claim: string;
15
14
  uid: string;
16
15
  vmid: number;
17
16
  };
17
+ untagged: boolean;
18
18
  cloneName: string;
19
19
  claim: string;
20
20
  vmid: number;
@@ -22,6 +22,12 @@ export interface ClonePlan {
22
22
  ref: string;
23
23
  vmid: number;
24
24
  };
25
+ node: StigNodeName;
26
+ host: string;
27
+ claimNode: string;
28
+ thin: string;
29
+ bridge: string;
30
+ vlan: number | null;
25
31
  ttlHours: number;
26
32
  poolPercent: number;
27
33
  poolMaxPercent: number;
@@ -47,19 +53,30 @@ export interface BuildPlan {
47
53
  }
48
54
  export declare class StigRunner {
49
55
  private readonly d;
50
- private static reencrypting;
51
- readonly node: StigNodeConfig;
52
- constructor(d: StigDeps, node?: StigNode);
56
+ /** Nodes with a clone re-encrypting in this process: one per node, nodes are independent. */
57
+ private static readonly reencrypting;
58
+ private readonly defaultNode;
59
+ /** `node` is the default for calls that do not name one (create_stig_claim's own `node` argument wins). */
60
+ constructor(d: StigDeps, node?: string);
61
+ get node(): StigNode;
53
62
  private log;
63
+ private sh;
54
64
  private listGuests;
65
+ /** VMIDs are per node: a live guest on the node, or a claim on the node's claim node, occupies one. */
55
66
  private pickVmid;
67
+ /** The canonical lab-inventory clone puts guests on vmbr0; nodes that clone onto another bridge use the root-SSH path. */
68
+ private providerFor;
56
69
  private poolPercent;
70
+ /** The node a claim lives on: its spec.node, which an explicit `node` argument must agree with. */
71
+ private nodeOfClaim;
57
72
  private newestTemplate;
58
73
  private step;
59
74
  private ensureDir;
60
75
  private writeSecretFile;
76
+ private buildNode;
61
77
  planClone(i: {
62
78
  cloneName: string;
79
+ node?: string;
63
80
  templateRef?: string;
64
81
  ttlHours?: number;
65
82
  dns?: string[];
@@ -70,6 +87,7 @@ export declare class StigRunner {
70
87
  }): Promise<ClonePlan>;
71
88
  createClone(i: {
72
89
  cloneName: string;
90
+ node?: string;
73
91
  apply?: boolean;
74
92
  planDigest?: string;
75
93
  templateRef?: string;
@@ -106,20 +124,29 @@ export declare class StigRunner {
106
124
  private annotateSummary;
107
125
  validate(i: {
108
126
  cloneName: string;
127
+ node?: string;
109
128
  requireDisa?: boolean;
110
129
  }): Promise<any>;
111
130
  getReceipt(i: {
112
131
  claim: string;
132
+ node?: string;
113
133
  }): Promise<{
114
134
  claim: string;
135
+ node: StigNodeName;
115
136
  descriptor: Record<string, any> | null;
116
137
  summary: StigReceiptSummary | null;
117
138
  statusPreview: ReturnType<typeof stigStatusPreview>;
118
139
  }>;
119
140
  releaseTemplate(i: {
120
141
  claim: string;
142
+ node?: string;
121
143
  confirmDestroy: boolean;
122
144
  }): Promise<any>;
123
145
  }
146
+ /** The clone verification of clone.sh `verify` (FIPS, SELinux, LUKS2 + TPM2, bootstrap key, recovery test); throws unless the gate holds. */
147
+ export declare function verifyGuestFacts(d: Pick<StigDeps, "guest" | "secrets" | "readAsset">, escrowKey: string, sudoStdin: (extra?: string) => string): Promise<{
148
+ kv: Record<string, string>;
149
+ text: string;
150
+ }>;
124
151
  export type { StigCounts, StigDescriptor, ClaimRecord };
125
152
  //# sourceMappingURL=runner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/stig/runner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/stig/runner.ts"],"names":[],"mappings":"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"}
@@ -7,52 +7,61 @@
7
7
  * Plans are pure. Applies need the digest of the plan they were shown.
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { STIG_RECEIPT_ANNOTATION, STIG_STEP_ANNOTATION, STIG_STEPS, STIG_PINS, STIG_DATASTREAM_SHA256, DISA_BENCHMARK_SHA256, STIG_TAILORING_SHA256, STIG_DVD_ISO, STIG_TEMPLATE_PREFIX, STIG_ANNOTATION_MAX_BYTES, STIG_POOL_LIMIT_PERCENT, COUNT_KEYS, assertPoolBelow, assertSingleReencrypt, assertNoYoungStigClone, disaCountsFromKv, DISA_PROFILE_ID, assertTemplateReleaseAllowed, buildReceiptSummary, capTtl, countsFromKv, defaultDescriptor, parseKv, planTemplateRenewal, receiptGate, redact, stigDescriptor, stigStatusPreview, validateReceiptSummary, } from "./model.js";
13
13
  import { sha256File } from "./receipt.js";
14
- import { stigNode } from "./nodes.js";
14
+ import { clearStage, cloneIdentity, markStage } from "./markers.js";
15
+ import { STIG_BUILD_NODE, STIG_PROVIDER_BRIDGE, nodeForClaimNode, resolveStigNode } from "./nodes.js";
16
+ import { VMID_RANGE, listGuests as listGuestsOn, lowestFreeVmid, parsePvesmStatus, shq } from "./pve.js";
17
+ export const STIG_NODE_ANNOTATION = "calltelemetry.com/stig-node";
18
+ export { VMID_RANGE };
15
19
  export const BUILD_CLAIM_TTL_HOURS = 6;
16
20
  export const DEFAULT_CLONE_TTL_HOURS = 48;
17
- export const VMID_RANGE = { min: 8100, max: 8899 };
18
21
  const CI_USER = "ctadmin";
19
22
  const DEFAULT_DNS = ["192.168.126.1", "192.168.68.125"];
20
23
  const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
21
- const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
22
- const dopplerKeyFor = (cloneName) => `LAB_RHEL8_RECOVERY_${cloneName.toUpperCase().replace(/-/g, "_")}`;
24
+ export const dopplerKeyFor = (cloneName) => `LAB_RHEL8_RECOVERY_${cloneName.toUpperCase().replace(/-/g, "_")}`;
23
25
  export class StigRunner {
24
26
  d;
25
- static reencrypting = false;
26
- node;
27
- constructor(d, node = "minis") {
27
+ /** Nodes with a clone re-encrypting in this process: one per node, nodes are independent. */
28
+ static reencrypting = new Set();
29
+ defaultNode;
30
+ /** `node` is the default for calls that do not name one (create_stig_claim's own `node` argument wins). */
31
+ constructor(d, node = STIG_BUILD_NODE) {
28
32
  this.d = d;
29
- this.node = stigNode(node);
33
+ this.defaultNode = resolveStigNode(node);
30
34
  }
35
+ get node() { return this.defaultNode; }
31
36
  log(line) { this.d.log.info(redact(line)); }
32
37
  /* ------------------------------------------------------------ helpers */
33
- async listGuests() {
34
- const r = await this.d.pve.run("pvesh get /cluster/resources --type vm --output-format json");
35
- if (r.code !== 0)
36
- throw new Error("could not list Proxmox guests");
37
- return JSON.parse(r.stdout);
38
+ sh(node) { return this.d.pveFor(node.host); }
39
+ listGuests(node) { return listGuestsOn(this.sh(node)); }
40
+ /** VMIDs are per node: a live guest on the node, or a claim on the node's claim node, occupies one. */
41
+ async pickVmid(node, guests) {
42
+ const claims = (await this.d.claims.list()).filter((c) => (c.spec?.node ?? "minis") === node.claimNode);
43
+ return lowestFreeVmid(guests ?? (await this.listGuests(node)), claims);
38
44
  }
39
- async pickVmid(guests) {
40
- const live = guests ?? (await this.listGuests());
41
- const used = new Set(live.map((g) => g.vmid));
42
- for (const c of await this.d.claims.list())
43
- if (Number.isInteger(c.spec?.vmid) && (c.spec?.node ?? "minis") === this.node.claimNode)
44
- used.add(c.spec.vmid);
45
- for (let v = VMID_RANGE.min; v <= VMID_RANGE.max; v++)
46
- if (!used.has(v))
47
- return v;
48
- throw new Error(`no free VMID in ${VMID_RANGE.min}-${VMID_RANGE.max}`);
45
+ /** The canonical lab-inventory clone puts guests on vmbr0; nodes that clone onto another bridge use the root-SSH path. */
46
+ providerFor(node, forClone = false) {
47
+ if (!this.d.provider)
48
+ return undefined;
49
+ return forClone && node.bridge !== STIG_PROVIDER_BRIDGE ? undefined : this.d.provider;
49
50
  }
50
- async poolPercent() {
51
- const r = await this.d.pve.run("pvesm status --storage local-lvm");
52
- const row = r.stdout.split("\n")[1] ?? "";
53
- const last = row.trim().split(/\s+/).pop() ?? "";
54
- const n = Number.parseFloat(last.replace("%", ""));
55
- return Number.isFinite(n) ? Math.floor(n) : Number.NaN;
51
+ async poolPercent(node) {
52
+ return parsePvesmStatus((await this.sh(node).run(`pvesm status --storage ${node.thin}`)).stdout).percent;
53
+ }
54
+ /** The node a claim lives on: its spec.node, which an explicit `node` argument must agree with. */
55
+ nodeOfClaim(rec, explicit) {
56
+ const fromClaim = nodeForClaimNode(rec.spec?.node);
57
+ if (!fromClaim)
58
+ throw new Error(`claim ${rec.metadata.name} is on node '${rec.spec?.node}', which is not in the STIG node map`);
59
+ if (explicit !== undefined) {
60
+ const want = resolveStigNode(explicit);
61
+ if (want.name !== fromClaim.name)
62
+ throw new Error(`claim ${rec.metadata.name} lives on ${fromClaim.name}, not ${want.name}`);
63
+ }
64
+ return fromClaim;
56
65
  }
57
66
  newestTemplate(guests) {
58
67
  return guests.filter((g) => g.template === 1 && g.name.startsWith(STIG_TEMPLATE_PREFIX)).sort((a, b) => (a.name < b.name ? -1 : 1)).pop();
@@ -63,7 +72,10 @@ export class StigRunner {
63
72
  ensureDir(path) { mkdirSync(path, { recursive: true, mode: 0o700 }); chmodSync(path, 0o700); return path; }
64
73
  writeSecretFile(path, content) { writeFileSync(path, content, { mode: 0o600 }); chmodSync(path, 0o600); }
65
74
  /* ------------------------------------------------------------ clone */
75
+ buildNode() { return resolveStigNode(STIG_BUILD_NODE); }
66
76
  async planClone(i) {
77
+ const node = i.node ? resolveStigNode(i.node) : this.defaultNode;
78
+ const untagged = i.untagged === true;
67
79
  if (!/^[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?$/.test(i.cloneName))
68
80
  throw new Error("cloneName must be a lowercase DNS label (a-z, 0-9, -)");
69
81
  const ttlHours = capTtl(i.ttlHours ?? DEFAULT_CLONE_TTL_HOURS);
@@ -84,18 +96,18 @@ export class StigRunner {
84
96
  throw new Error(`claim ${claim} is not a STIG clone claim; refusing to recreate it`);
85
97
  recreate = { claim, uid: existing.metadata.uid, vmid: existing.spec?.vmid };
86
98
  }
87
- const guests = await this.listGuests();
99
+ const guests = await this.listGuests(node);
88
100
  const tpl = i.templateRef ? guests.find((g) => g.template === 1 && g.name === i.templateRef) : this.newestTemplate(guests);
89
101
  if (!tpl)
90
- throw new Error(i.templateRef ? `template ${i.templateRef} not found` : `no ${STIG_TEMPLATE_PREFIX}* template found`);
91
- const vmid = await this.pickVmid(guests);
92
- const poolPercent = await this.poolPercent();
102
+ throw new Error(i.templateRef ? `template ${i.templateRef} not found on ${node.name}` : `no ${STIG_TEMPLATE_PREFIX}* template found on ${node.name}`);
103
+ const vmid = await this.pickVmid(node, guests);
104
+ const poolPercent = await this.poolPercent(node);
93
105
  const poolMaxPercent = i.poolMaxPercent ?? STIG_POOL_LIMIT_PERCENT;
94
106
  if (!Number.isInteger(poolMaxPercent) || poolMaxPercent < 10 || poolMaxPercent > STIG_POOL_LIMIT_PERCENT)
95
107
  throw new Error(`poolMaxPercent must be 10-${STIG_POOL_LIMIT_PERCENT}`);
96
108
  let blockedBy = null;
97
- if (this.node.vlan === null && !i.untagged)
98
- blockedBy = `node ${this.node.claimNode}'s bridge is not VLAN-aware; a VLAN126 clone gets no network there. Pass untagged=true for an untagged clone.`;
109
+ if (node.vlan === null && !untagged)
110
+ blockedBy = `node ${node.name} has no VLAN configured for clones; a tagged clone is not possible there. Pass untagged=true for an untagged clone.`;
99
111
  try {
100
112
  if (!blockedBy)
101
113
  assertPoolBelow(poolPercent, poolMaxPercent);
@@ -105,16 +117,17 @@ export class StigRunner {
105
117
  }
106
118
  if (!blockedBy) {
107
119
  try {
108
- assertSingleReencrypt((await this.d.claims.list()).filter((c) => (c.spec?.node ?? "minis") === this.node.claimNode), claim);
120
+ assertSingleReencrypt(await this.d.claims.list(), claim, node.claimNode);
109
121
  assertNoYoungStigClone(guests);
110
122
  }
111
123
  catch (e) {
112
124
  blockedBy = e.message;
113
125
  }
114
126
  }
115
- const base = { cloneName: i.cloneName, claim, vmid, template: { ref: tpl.name, vmid: tpl.vmid }, ttlHours, dns: i.dns ?? DEFAULT_DNS, ntp: i.ntp ?? [], poolMaxPercent, node: this.node.claimNode, untagged: !!i.untagged, recreate: recreate ?? null };
127
+ const base = { cloneName: i.cloneName, node: node.name, host: node.host, thin: node.thin, bridge: node.bridge, vlan: node.vlan, claim, vmid, template: { ref: tpl.name, vmid: tpl.vmid }, ttlHours, dns: i.dns ?? DEFAULT_DNS, ntp: i.ntp ?? [], poolMaxPercent, untagged, recreate: recreate ?? null };
116
128
  return {
117
- mode: "plan", node: this.node.claimNode, ...(recreate ? { recreate } : {}), cloneName: i.cloneName, claim, vmid, template: base.template, ttlHours, poolPercent, poolMaxPercent, poolOk: blockedBy === null, blockedBy,
129
+ mode: "plan", ...(recreate ? { recreate } : {}), untagged, cloneName: i.cloneName, node: node.name, host: node.host, claimNode: node.claimNode, thin: node.thin, bridge: node.bridge, vlan: node.vlan,
130
+ claim, vmid, template: base.template, ttlHours, poolPercent, poolMaxPercent, poolOk: blockedBy === null, blockedBy,
118
131
  escrowKey: dopplerKeyFor(i.cloneName), steps: STIG_STEPS.clone, planDigest: digest({ kind: "clone", ...base }),
119
132
  };
120
133
  }
@@ -124,6 +137,7 @@ export class StigRunner {
124
137
  return plan;
125
138
  if (!i.planDigest || i.planDigest !== plan.planDigest)
126
139
  throw new Error("plan digest missing or stale; re-run the plan and apply with its planDigest");
140
+ const node = resolveStigNode(plan.node);
127
141
  if (plan.recreate) {
128
142
  // Release the old clone first: the operator destroys its guest when the claim is deleted.
129
143
  this.log(`recreate: releasing claim ${plan.recreate.claim} (vmid ${plan.recreate.vmid})`);
@@ -132,28 +146,29 @@ export class StigRunner {
132
146
  await this.d.clock.sleep(10_000);
133
147
  if (await this.d.claims.get(plan.recreate.claim))
134
148
  throw new Error(`claim ${plan.recreate.claim} is still present after release; not creating its replacement`);
135
- for (let n = 0; n < 60 && (await this.listGuests()).some((g) => g.vmid === plan.recreate.vmid); n++)
149
+ for (let n = 0; n < 60 && (await this.listGuests(node)).some((g) => g.vmid === plan.recreate.vmid); n++)
136
150
  await this.d.clock.sleep(10_000);
137
151
  plan = await this.planClone({ ...i, recreate: false });
138
152
  }
139
153
  // Guards run before anything is created: a refused request leaves no claim and no guest.
140
- if (plan.blockedBy && /VLAN-aware/.test(plan.blockedBy))
154
+ if (plan.blockedBy && /no VLAN configured/.test(plan.blockedBy))
141
155
  throw new Error(plan.blockedBy);
142
156
  assertPoolBelow(plan.poolPercent, plan.poolMaxPercent);
143
- assertSingleReencrypt((await this.d.claims.list()).filter((c) => (c.spec?.node ?? "minis") === this.node.claimNode), plan.claim);
144
- assertNoYoungStigClone(await this.listGuests());
145
- if (StigRunner.reencrypting)
146
- throw new Error("another clone is re-encrypting in this process; only one at a time");
147
- StigRunner.reencrypting = true;
157
+ assertSingleReencrypt(await this.d.claims.list(), plan.claim, node.claimNode);
158
+ assertNoYoungStigClone(await this.listGuests(node));
159
+ if (StigRunner.reencrypting.has(node.name))
160
+ throw new Error(`another clone is re-encrypting on ${node.name} in this process; only one at a time per node`);
161
+ StigRunner.reencrypting.add(node.name);
148
162
  try {
149
163
  return await this.applyClone(plan, i);
150
164
  }
151
165
  finally {
152
- StigRunner.reencrypting = false;
166
+ StigRunner.reencrypting.delete(node.name);
153
167
  }
154
168
  }
155
169
  async applyClone(plan, i) {
156
170
  const { cloneName, claim, vmid, template, ttlHours, escrowKey } = plan;
171
+ const node = resolveStigNode(plan.node);
157
172
  const state = this.ensureDir(join(this.d.baseDir, `clone-${cloneName}`));
158
173
  this.ensureDir(join(state, "evidence"));
159
174
  const keys = await this.d.materials.sshKeypair(state, `claim-${claim}`);
@@ -162,55 +177,58 @@ export class StigRunner {
162
177
  this.writeSecretFile(join(state, "ctadmin_password"), adminPassword);
163
178
  writeFileSync(join(state, "vmid"), `${vmid}\n`);
164
179
  writeFileSync(join(state, "template"), `${template.ref}\n`);
165
- writeFileSync(join(state, "node"), `${this.node.claimNode}\n`);
180
+ writeFileSync(join(state, "node"), `${node.name}\n`);
166
181
  const knownHosts = join(state, "known_hosts");
167
182
  this.d.guest.configure({ user: CI_USER, keyFile: keys.privateKeyFile, knownHostsFile: knownHosts });
168
183
  const sudoStdin = (extra = "") => `${adminPassword}\n${extra}`;
169
184
  // 1. claim first, wait for the operator, then (and only then) touch Proxmox.
170
- this.log(`claim ${claim}: creating (vmid ${vmid}, ${ttlHours}h)`);
185
+ this.log(`claim ${claim}: creating (vmid ${vmid}, ${ttlHours}h) on ${node.name}`);
171
186
  // The descriptor is spec.stig, set at creation: immutable and part of the claim's custody request.
172
187
  const descriptor = defaultDescriptor("clone", { template, adminKeyRef: `doppler:${escrowKey}`, dns: i.dns ?? DEFAULT_DNS, ntp: i.ntp ?? [], tailoringSha256: STIG_TAILORING_SHA256 });
173
- await this.d.claims.create({ name: claim, vmid, vmName: cloneName, ttlHours, node: this.node.claimNode, stig: descriptor });
188
+ await this.d.claims.create({ name: claim, vmid, vmName: cloneName, ttlHours, node: node.claimNode, stig: descriptor });
174
189
  await this.d.claims.waitReady(claim);
175
- await this.d.claims.annotate(claim, { [STIG_STEP_ANNOTATION]: "claim" });
190
+ // The node rides on the claim so later verbs (validate, receipt, release) resolve it without being told.
191
+ await this.d.claims.annotate(claim, { [STIG_STEP_ANNOTATION]: "claim", [STIG_NODE_ANNOTATION]: node.name });
176
192
  await this.step(claim, "pool-guard");
177
193
  // 2. clone, fresh TPM, cloud-init, VLAN126 (the canonical lab-inventory verb, or root-SSH qm as the fallback)
178
194
  await this.step(claim, "clone");
179
- this.log(`proxmox: full clone of ${template.ref} (${template.vmid}) to ${vmid}`);
180
- if (this.d.provider) {
181
- const others = (await this.d.claims.list()).filter((c) => c.metadata.name !== claim && (c.spec?.node ?? "minis") === this.node.claimNode).map((c) => c.spec?.vmid).filter((v) => Number.isInteger(v));
182
- await this.d.provider.clone({ operationId: `stig-clone-${claim}-${this.d.clock.now()}`, hostAssetId: this.node.hostAssetId, untagged: this.node.vlan === null, name: cloneName, templateName: template.ref, vmid, claimedVmids: others, sshPublicKey: keys.publicKey, passwordHash: adminHash, dns: i.dns ?? DEFAULT_DNS });
195
+ this.log(`proxmox ${node.name}: full clone of ${template.ref} (${template.vmid}) to ${vmid}`);
196
+ // The canonical lab-inventory stig verbs address the build node only; the other nodes use the root-SSH fallback.
197
+ const provider = this.providerFor(node, true);
198
+ if (provider) {
199
+ const others = (await this.d.claims.list()).filter((c) => c.metadata.name !== claim && (c.spec?.node ?? "minis") === node.claimNode).map((c) => c.spec?.vmid).filter((v) => Number.isInteger(v));
200
+ await provider.clone({ operationId: `stig-clone-${claim}-${this.d.clock.now()}`, hostAssetId: node.hostAssetId, untagged: plan.untagged || node.vlan === null, name: cloneName, templateName: template.ref, vmid, claimedVmids: others, sshPublicKey: keys.publicKey, passwordHash: adminHash, dns: i.dns ?? DEFAULT_DNS });
183
201
  await this.step(claim, "tpm");
184
202
  await this.step(claim, "cloud-init");
185
203
  }
186
204
  else {
187
- await this.pveOk(`set -e\nqm clone ${template.vmid} ${vmid} --name ${cloneName} --full 1 --storage local-lvm --description ${shq(`Claim ${claim}; clone of ${template.ref} (RHEL 8.10 STIG)`)}`);
205
+ await this.pveOk(`set -e\nqm clone ${template.vmid} ${vmid} --name ${cloneName} --full 1 --storage ${node.thin} --description ${shq(`Claim ${claim}; clone of ${template.ref} (RHEL 8.10 STIG)`)}`, undefined, node);
188
206
  await this.step(claim, "tpm");
189
207
  // Never shared with the template or another clone.
190
- await this.pveOk(`set -e\nif qm config ${vmid} | grep -q '^tpmstate0'; then qm set ${vmid} --delete tpmstate0; fi\nqm set ${vmid} --tpmstate0 local-lvm:1,version=v2.0`);
208
+ await this.pveOk(`set -e\nif qm config ${vmid} | grep -q '^tpmstate0'; then qm set ${vmid} --delete tpmstate0; fi\nqm set ${vmid} --tpmstate0 ${node.thin}:1,version=v2.0`, undefined, node);
191
209
  await this.step(claim, "cloud-init");
192
210
  const dns = (i.dns ?? DEFAULT_DNS).join(" ");
193
211
  // The password hash and the public key arrive on stdin; PVE would otherwise hash with sha256-crypt (STIG wants sha512).
194
212
  await this.pveOk(`set -e\numask 077\nP=/tmp/${claim}.pub; W=/tmp/${claim}.pw\ncat > "$P" <<'KEY'\n${keys.publicKey}\nKEY\nIFS= read -r HASH\nprintf '%s' "$HASH" > "$W"\n` +
195
213
  `qm set ${vmid} --ciuser ${CI_USER} --cipassword "$(cat "$W")" --sshkeys "$P" --ipconfig0 ip=dhcp --nameserver ${shq(dns)} --agent 1 --tags rhel8-stig-clone >/dev/null\n` +
196
- `qm set ${vmid} --net0 "$(qm config ${vmid} | sed -n 's/^net0: \\([^,]*\\),.*/\\1/p'),bridge=vmbr0${this.node.vlan ? `,tag=${this.node.vlan}` : ""}"\nrm -f "$P" "$W"`, { stdin: `${adminHash}\n` });
214
+ `qm set ${vmid} --net0 "$(qm config ${vmid} | sed -n 's/^net0: \\([^,]*\\),.*/\\1/p'),bridge=${node.bridge}${plan.untagged || node.vlan === null ? "" : `,tag=${node.vlan}`}"\nrm -f "$P" "$W"`, { stdin: `${adminHash}\n` }, node);
197
215
  }
198
216
  // 3. boot, first-boot re-key, TPM bind
199
217
  await this.step(claim, "boot");
200
- if (!this.d.provider)
201
- await this.pveOk(`qm start ${vmid}`);
218
+ if (!provider)
219
+ await this.pveOk(`qm start ${vmid}`, undefined, node);
202
220
  let ip = "";
203
221
  for (let n = 0; n < 60 && !ip; n++) {
204
- if (this.d.provider) {
222
+ if (provider) {
205
223
  try {
206
- ip = await this.d.provider.guestIp(vmid, this.node.hostAssetId);
224
+ ip = await provider.guestIp(vmid, node.hostAssetId);
207
225
  }
208
226
  catch {
209
227
  ip = "";
210
228
  }
211
229
  }
212
230
  else {
213
- const r = await this.d.pve.run(`qm guest cmd ${vmid} network-get-interfaces`);
231
+ const r = await this.sh(node).run(`qm guest cmd ${vmid} network-get-interfaces`);
214
232
  if (r.code === 0)
215
233
  ip = guestIp(r.stdout);
216
234
  }
@@ -254,34 +272,21 @@ export class StigRunner {
254
272
  writeFileSync(join(state, "verify.txt"), verify.text);
255
273
  // 6. fstrim: re-encryption fully allocates the thin disk
256
274
  await this.step(claim, "trim");
257
- const before = await this.poolPercent();
275
+ const before = await this.poolPercent(node);
258
276
  await this.d.guest.runScript(this.d.readAsset("guest/trim.sh"), { sudo: true, stdin: sudoStdin() });
259
- const after = await this.poolPercent();
277
+ const after = await this.poolPercent(node);
260
278
  this.log(`pool before trim ${before}%, after ${after}%`);
261
279
  await this.step(claim, "complete");
262
- return { ok: true, claim, vmid, name: cloneName, ip, template: template.ref, escrowKey, verify: verify.kv, poolBefore: before, poolAfter: after, stateDir: state };
280
+ return { ok: true, node: node.name, claim, vmid, name: cloneName, ip, template: template.ref, escrowKey, verify: verify.kv, poolBefore: before, poolAfter: after, stateDir: state };
263
281
  }
264
- async pveOk(script, opts) {
265
- const r = await this.d.pve.run(script, opts);
282
+ async pveOk(script, opts, node = this.buildNode()) {
283
+ const r = await this.sh(node).run(script, opts);
266
284
  if (r.code !== 0)
267
285
  throw new Error(`proxmox command failed (exit ${r.code}): ${redact(r.stderr).slice(0, 200)}`);
268
286
  return r;
269
287
  }
270
- async verifyGuest(escrowKey, sudoStdin) {
271
- const user = await this.d.guest.runScript(this.d.readAsset("guest/verify-user.sh"));
272
- const root = await this.d.guest.runScript(this.d.readAsset("guest/verify-root.sh"), { sudo: true, stdin: sudoStdin() });
273
- let text = `${user.stdout.trim()}\n${root.stdout.trim()}`;
274
- const rec = await this.d.secrets.get(escrowKey);
275
- if (rec) {
276
- const t = await this.d.guest.runScript(this.d.readAsset("guest/test-recovery.sh"), { sudo: true, stdin: sudoStdin(rec) });
277
- text += `\nrecovery_passphrase_test=${t.stdout.trim().split("\n").pop() ?? ""}`;
278
- }
279
- text += "\n";
280
- const kv = parseKv(text);
281
- const okGate = kv.fips_enabled === "1" && kv.selinux === "Enforcing" && kv.tpm_bound === "1" && kv.bootstrap_key_file === "absent";
282
- if (!okGate)
283
- throw new Error("VERIFY FAILED: fips/selinux/tpm/bootstrap-key checks did not all pass");
284
- return { kv, text };
288
+ verifyGuest(escrowKey, sudoStdin) {
289
+ return verifyGuestFacts(this.d, escrowKey, sudoStdin);
285
290
  }
286
291
  /* ------------------------------------------------------------ build */
287
292
  async planBuild(i) {
@@ -296,7 +301,7 @@ export class StigRunner {
296
301
  const dvd = iso.code === 0 && iso.stdout.includes(STIG_DVD_ISO.split("/").pop());
297
302
  if (!dvd)
298
303
  throw new Error(`RHEL 8.10 DVD ${STIG_DVD_ISO} is not present on Proxmox storage`);
299
- const vmid = await this.pickVmid();
304
+ const vmid = await this.pickVmid(this.buildNode());
300
305
  const date = (i.now ?? new Date(this.d.clock.now()).toISOString().slice(0, 10)).replace(/-/g, "");
301
306
  const name = i.name ?? `${STIG_TEMPLATE_PREFIX}${date}-${vmid}`;
302
307
  if (!/^rhel810-stig-\d{8}-\d{4}$/.test(name))
@@ -323,7 +328,7 @@ export class StigRunner {
323
328
  // claim first
324
329
  this.log(`claim ${claim}: creating (vmid ${vmid}, ${BUILD_CLAIM_TTL_HOURS}h)`);
325
330
  const descriptor = defaultDescriptor("build-template", { adminKeyRef: "doppler:LAB_PROXMOX_MINIS_API_TOKEN", tailoringSha256: STIG_TAILORING_SHA256, dns: i.dns, ntp: i.ntp });
326
- await this.d.claims.create({ name: claim, vmid, vmName: name, ttlHours: BUILD_CLAIM_TTL_HOURS, node: this.node.claimNode, stig: descriptor });
331
+ await this.d.claims.create({ name: claim, vmid, vmName: name, ttlHours: BUILD_CLAIM_TTL_HOURS, node: this.buildNode().claimNode, stig: descriptor });
327
332
  await this.d.claims.waitReady(claim);
328
333
  await this.d.claims.annotate(claim, { [STIG_STEP_ANNOTATION]: "claim" });
329
334
  // secrets by NAME, straight into the Packer environment
@@ -408,6 +413,7 @@ export class StigRunner {
408
413
  const rec = await this.d.claims.get(claim);
409
414
  if (!rec)
410
415
  throw new Error(`claim ${claim} not found`);
416
+ const node = this.nodeOfClaim(rec, i.node); // the scan runs over SSH to the guest; the node is checked, not contacted
411
417
  const pw = readFileSync(join(state, "ctadmin_password"), "utf8");
412
418
  const ip = readFileSync(join(state, "ip"), "utf8").trim();
413
419
  const tpl = readFileSync(join(state, "template"), "utf8").trim();
@@ -422,8 +428,14 @@ export class StigRunner {
422
428
  this.log(`oscap DISA V2R8 benchmark scan on ${i.cloneName} (skipped on templates that predate the dual-scan recipe)`);
423
429
  await this.d.guest.runScript(this.d.readAsset("guest/scan-disa.sh"), { sudo: true, stdin: sudoStdin(), timeoutMs: 30 * 60_000 });
424
430
  const evDir = join(state, "evidence");
431
+ clearStage(state, "scan");
432
+ clearStage(state, "keyhash");
433
+ rmSync(evDir, { recursive: true, force: true }); // never mix this scan with a predecessor's files (clone.sh scan clears it too)
425
434
  await this.d.guest.fetchDir("/var/tmp/ct-scan", evDir);
426
435
  await this.d.guest.runScript("rm -rf /var/tmp/ct-scan\n", { sudo: true, stdin: sudoStdin() });
436
+ // The same markers evidence.sh keeps: collect_stig_evidence skips a scan that is current for this guest.
437
+ const identity = cloneIdentity(tpl, readFileSync(join(state, "vmid"), "utf8").trim(), rec.metadata.uid);
438
+ markStage(state, "scan", identity);
427
439
  const counts = countsFromKv(parseKv(readFileSync(join(evDir, "clone-summary.txt"), "utf8")));
428
440
  let disa;
429
441
  if (!existsSync(join(evDir, "clone-disa-absent")) && existsSync(join(evDir, "clone-disa-summary.txt"))) {
@@ -443,8 +455,10 @@ export class StigRunner {
443
455
  if (/^[a-f0-9]{64}$/.test(hex))
444
456
  keyDigest = hex;
445
457
  }
446
- if (keyDigest)
458
+ if (keyDigest) {
447
459
  writeFileSync(join(state, "keyhash.txt"), `${keyDigest}\n`, { mode: 0o600 });
460
+ markStage(state, "keyhash", identity);
461
+ }
448
462
  const verifyKv = existsSync(join(state, "verify.txt")) ? parseKv(readFileSync(join(state, "verify.txt"), "utf8")) : {};
449
463
  const rhel = /(\d+\.\d+) \(/.exec(verifyKv.PRETTY_NAME ?? "")?.[1] ?? "8.10";
450
464
  const summary = buildReceiptSummary({
@@ -456,7 +470,7 @@ export class StigRunner {
456
470
  await this.annotateSummary(claim, summary);
457
471
  await this.step(claim, "complete");
458
472
  const ok = gate.failZero && !(i.requireDisa && disaMissing);
459
- return { ok, claim, counts, gate, disa: disa ?? null, disaScan: disa ? "ran" : "unavailable (template predates the DISA dual-scan recipe)", keyDigest, summary, evidenceDir: evDir };
473
+ return { ok, node: node.name, claim, counts, gate, disa: disa ?? null, disaScan: disa ? "ran" : "unavailable (template predates the DISA dual-scan recipe)", keyDigest, summary, evidenceDir: evDir };
460
474
  }
461
475
  async getReceipt(i) {
462
476
  const rec = await this.d.claims.get(i.claim);
@@ -472,27 +486,29 @@ export class StigRunner {
472
486
  summary = null;
473
487
  }
474
488
  }
475
- return { claim: i.claim, descriptor: stigDescriptor(rec), summary, statusPreview: stigStatusPreview(rec) };
489
+ return { claim: i.claim, node: this.nodeOfClaim(rec, i.node).name, descriptor: stigDescriptor(rec), summary, statusPreview: stigStatusPreview(rec) };
476
490
  }
477
491
  /* ---------------------------------------------------------- release */
478
492
  async releaseTemplate(i) {
479
493
  const rec = await this.d.claims.get(i.claim);
480
494
  if (!rec)
481
495
  throw new Error(`claim ${i.claim} not found`);
496
+ const node = this.nodeOfClaim(rec, i.node);
482
497
  const vmid = rec.spec?.vmid;
483
- const guest = (await this.listGuests()).find((g) => g.vmid === vmid);
498
+ const guest = (await this.listGuests(node)).find((g) => g.vmid === vmid);
484
499
  if (!guest)
485
- throw new Error(`no Proxmox guest ${vmid} for claim ${i.claim}`);
500
+ throw new Error(`no Proxmox guest ${vmid} on ${node.name} for claim ${i.claim}`);
486
501
  assertTemplateReleaseAllowed({ guest: guest, claim: rec, destroyConfirmed: i.confirmDestroy });
487
- this.log(`destroying template ${guest.name} (${guest.vmid}) before releasing claim ${i.claim}`);
488
- if (this.d.provider)
489
- await this.d.provider.destroyTemplate({ operationId: `stig-destroy-${i.claim}-${this.d.clock.now()}`, hostAssetId: this.node.hostAssetId, name: guest.name, vmid: guest.vmid });
502
+ this.log(`destroying template ${guest.name} (${guest.vmid}) on ${node.name} before releasing claim ${i.claim}`);
503
+ const provider = this.providerFor(node);
504
+ if (provider)
505
+ await provider.destroyTemplate({ operationId: `stig-destroy-${i.claim}-${this.d.clock.now()}`, hostAssetId: node.hostAssetId, name: guest.name, vmid: guest.vmid });
490
506
  else
491
- await this.pveOk(`qm destroy ${guest.vmid} --purge 1 --destroy-unreferenced-disks 1`);
492
- if ((await this.listGuests()).some((g) => g.vmid === guest.vmid))
507
+ await this.pveOk(`qm destroy ${guest.vmid} --purge 1 --destroy-unreferenced-disks 1`, undefined, node);
508
+ if ((await this.listGuests(node)).some((g) => g.vmid === guest.vmid))
493
509
  throw new Error(`template ${guest.vmid} still exists after destroy; claim retained`);
494
510
  await this.d.claims.delete(i.claim, rec.metadata.uid);
495
- return { ok: true, destroyed: { vmid: guest.vmid, name: guest.name }, claimDeleted: i.claim };
511
+ return { ok: true, node: node.name, destroyed: { vmid: guest.vmid, name: guest.name }, claimDeleted: i.claim };
496
512
  }
497
513
  }
498
514
  function guestIp(stdout) {
@@ -509,4 +525,21 @@ function guestIp(stdout) {
509
525
  catch { /* not ready yet */ }
510
526
  return "";
511
527
  }
528
+ /** The clone verification of clone.sh `verify` (FIPS, SELinux, LUKS2 + TPM2, bootstrap key, recovery test); throws unless the gate holds. */
529
+ export async function verifyGuestFacts(d, escrowKey, sudoStdin) {
530
+ const user = await d.guest.runScript(d.readAsset("guest/verify-user.sh"));
531
+ const root = await d.guest.runScript(d.readAsset("guest/verify-root.sh"), { sudo: true, stdin: sudoStdin() });
532
+ let text = `${user.stdout.trim()}\n${root.stdout.trim()}`;
533
+ const rec = await d.secrets.get(escrowKey);
534
+ if (rec) {
535
+ const t = await d.guest.runScript(d.readAsset("guest/test-recovery.sh"), { sudo: true, stdin: sudoStdin(rec) });
536
+ text += `\nrecovery_passphrase_test=${t.stdout.trim().split("\n").pop() ?? ""}`;
537
+ }
538
+ text += "\n";
539
+ const kv = parseKv(text);
540
+ const okGate = kv.fips_enabled === "1" && kv.selinux === "Enforcing" && kv.tpm_bound === "1" && kv.bootstrap_key_file === "absent";
541
+ if (!okGate)
542
+ throw new Error("VERIFY FAILED: fips/selinux/tpm/bootstrap-key checks did not all pass");
543
+ return { kv, text };
544
+ }
512
545
  //# sourceMappingURL=runner.js.map