@caelo-cms/provisioning 0.10.52 → 0.10.55

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 (145) hide show
  1. package/dist/agent/command.js +1 -0
  2. package/dist/agent/command.js.map +1 -1
  3. package/dist/agent-db.js +198 -102
  4. package/dist/audit-job-setup.d.ts +69 -0
  5. package/dist/audit-job-setup.d.ts.map +1 -0
  6. package/dist/audit-job-setup.js +317 -0
  7. package/dist/audit-job-setup.js.map +1 -0
  8. package/dist/aws-task-roles.d.ts +1 -1
  9. package/dist/aws-task-roles.d.ts.map +1 -1
  10. package/dist/aws-task-roles.js +0 -1
  11. package/dist/aws-task-roles.js.map +1 -1
  12. package/dist/caddy.d.ts +9 -0
  13. package/dist/caddy.d.ts.map +1 -1
  14. package/dist/caddy.js +38 -0
  15. package/dist/caddy.js.map +1 -1
  16. package/dist/cli-routing.d.ts +20 -0
  17. package/dist/cli-routing.d.ts.map +1 -1
  18. package/dist/cli-routing.js +106 -0
  19. package/dist/cli-routing.js.map +1 -1
  20. package/dist/cli.js +37 -14
  21. package/dist/cli.js.map +1 -1
  22. package/dist/compose.d.ts +4 -3
  23. package/dist/compose.d.ts.map +1 -1
  24. package/dist/compose.js +3 -2
  25. package/dist/compose.js.map +1 -1
  26. package/dist/gateway-credentials.d.ts +3 -5
  27. package/dist/gateway-credentials.d.ts.map +1 -1
  28. package/dist/gateway-credentials.js +8 -18
  29. package/dist/gateway-credentials.js.map +1 -1
  30. package/dist/gcp-names.d.ts +16 -1
  31. package/dist/gcp-names.d.ts.map +1 -1
  32. package/dist/gcp-names.js +23 -0
  33. package/dist/gcp-names.js.map +1 -1
  34. package/dist/infra-contract.d.ts +1 -1
  35. package/dist/infra-contract.d.ts.map +1 -1
  36. package/dist/infra-contract.js +14 -1
  37. package/dist/infra-contract.js.map +1 -1
  38. package/dist/private-file.d.ts +26 -0
  39. package/dist/private-file.d.ts.map +1 -0
  40. package/dist/private-file.js +49 -0
  41. package/dist/private-file.js.map +1 -0
  42. package/dist/release-contract.d.ts.map +1 -1
  43. package/dist/release-contract.js +3 -0
  44. package/dist/release-contract.js.map +1 -1
  45. package/dist/runtime-secrets.d.ts +1 -3
  46. package/dist/runtime-secrets.d.ts.map +1 -1
  47. package/dist/runtime-secrets.js +2 -6
  48. package/dist/runtime-secrets.js.map +1 -1
  49. package/dist/self-hosted-roles.d.ts +9 -8
  50. package/dist/self-hosted-roles.d.ts.map +1 -1
  51. package/dist/self-hosted-roles.js +9 -9
  52. package/dist/self-hosted-roles.js.map +1 -1
  53. package/dist/self-hosted-upgrade.d.ts +7 -1
  54. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  55. package/dist/self-hosted-upgrade.js +17 -4
  56. package/dist/self-hosted-upgrade.js.map +1 -1
  57. package/dist/stack-contract.d.ts +60 -3
  58. package/dist/stack-contract.d.ts.map +1 -1
  59. package/dist/stack-contract.js +49 -37
  60. package/dist/stack-contract.js.map +1 -1
  61. package/dist/tar-entries.d.ts.map +1 -1
  62. package/dist/tar-entries.js +84 -9
  63. package/dist/tar-entries.js.map +1 -1
  64. package/dist/upgrade/app-roll.d.ts.map +1 -1
  65. package/dist/upgrade/app-roll.js +35 -0
  66. package/dist/upgrade/app-roll.js.map +1 -1
  67. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  68. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  69. package/dist/upgrade/audit-job-roll.js +43 -0
  70. package/dist/upgrade/audit-job-roll.js.map +1 -0
  71. package/dist/upgrade/converge.d.ts.map +1 -1
  72. package/dist/upgrade/converge.js +38 -15
  73. package/dist/upgrade/converge.js.map +1 -1
  74. package/dist/upgrader-setup.d.ts.map +1 -1
  75. package/dist/upgrader-setup.js +9 -3
  76. package/dist/upgrader-setup.js.map +1 -1
  77. package/dist/wizards/gcp-cost.js +3 -3
  78. package/dist/wizards/gcp-cost.js.map +1 -1
  79. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  80. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  81. package/dist/wizards/gcp-pulumi.js +7 -0
  82. package/dist/wizards/gcp-pulumi.js.map +1 -1
  83. package/dist/wizards/gcp.d.ts.map +1 -1
  84. package/dist/wizards/gcp.js +32 -30
  85. package/dist/wizards/gcp.js.map +1 -1
  86. package/package.json +4 -4
  87. package/src/admin-memory.test.ts +1 -1
  88. package/src/agent/command.ts +1 -0
  89. package/src/agent/run.test.ts +1 -0
  90. package/src/audit-job-setup.test.ts +234 -0
  91. package/src/audit-job-setup.ts +360 -0
  92. package/src/aws-task-roles.ts +0 -2
  93. package/src/caddy-redirects.docker.test.ts +19 -6
  94. package/src/caddy-redirects.test.ts +1 -1
  95. package/src/caddy.test.ts +30 -0
  96. package/src/caddy.ts +43 -0
  97. package/src/checkpoint-secrets.test.ts +0 -2
  98. package/src/cli-init-validation.test.ts +44 -0
  99. package/src/cli-routing.test.ts +27 -1
  100. package/src/cli-routing.ts +131 -0
  101. package/src/cli.ts +41 -15
  102. package/src/compose.ts +13 -5
  103. package/src/gateway-credentials.test.ts +0 -29
  104. package/src/gateway-credentials.ts +11 -25
  105. package/src/gateway-env-isolation.test.ts +7 -2
  106. package/src/gcp-names.ts +28 -1
  107. package/src/index.test.ts +7 -6
  108. package/src/infra-contract.test.ts +4 -3
  109. package/src/infra-contract.ts +14 -1
  110. package/src/private-file.test.ts +80 -0
  111. package/src/private-file.ts +50 -0
  112. package/src/release-contract.ts +3 -0
  113. package/src/runtime-secrets.test.ts +9 -81
  114. package/src/runtime-secrets.ts +2 -6
  115. package/src/self-hosted-compose.test.ts +1 -1
  116. package/src/self-hosted-roles.integration.test.ts +23 -38
  117. package/src/self-hosted-roles.test.ts +11 -7
  118. package/src/self-hosted-roles.ts +17 -16
  119. package/src/self-hosted-upgrade.test.ts +28 -1
  120. package/src/self-hosted-upgrade.ts +29 -4
  121. package/src/stack-contract.test.ts +19 -11
  122. package/src/stack-contract.ts +54 -5
  123. package/src/stack-converge-secrets.test.ts +2 -0
  124. package/src/stack-converge.test.ts +3 -1
  125. package/src/tar-entries.test.ts +149 -7
  126. package/src/tar-entries.ts +90 -9
  127. package/src/upgrade/app-roll.ts +39 -0
  128. package/src/upgrade/audit-job-roll.ts +68 -0
  129. package/src/upgrade/converge.ts +47 -20
  130. package/src/upgrade/upgrade.test.ts +76 -2
  131. package/src/upgrader-assess.test.ts +1 -0
  132. package/src/upgrader-job.test.ts +1 -0
  133. package/src/upgrader-setup.test.ts +1 -0
  134. package/src/upgrader-setup.ts +9 -2
  135. package/src/wizards/gcp-cost.ts +3 -3
  136. package/src/wizards/gcp-pulumi.ts +7 -0
  137. package/src/wizards/gcp.ts +38 -31
  138. package/stacks/aws/README.md +1 -1
  139. package/stacks/aws/index.ts +9 -6
  140. package/stacks/azure/README.md +1 -1
  141. package/stacks/azure/index.ts +18 -6
  142. package/stacks/gcp/README.md +1 -1
  143. package/stacks/gcp/index.ts +14 -4
  144. package/stacks/gcp-firebase/index.ts +14 -4
  145. package/stacks/self-hosted/index.ts +4 -11
@@ -13,9 +13,15 @@
13
13
  * the same `migrate.ts` entrypoint the GCP migrate job runs) while the
14
14
  * old containers keep serving — migrations are expand-only, so the
15
15
  * running release works on the new schema;
16
- * 5. recreate the admin, health-check it (`/_caelo/health`, which proves
16
+ * 5. converge the database roles (the same script `cms-provision up`
17
+ * runs): bootstrap.sh only runs on the data volume's first start, so a
18
+ * role a release retires — #635's gateway_role — is dropped here, on
19
+ * every existing install. After the migrations, because the runner
20
+ * refuses an install that skipped the release the old role's last user
21
+ * was retired in (run-order.ts): past it, nothing running uses it;
22
+ * 6. recreate the admin, health-check it (`/_caelo/health`, which proves
17
23
  * the DB pool and roles); then the gateway (`/healthz`);
18
- * 6. record the release in `.caelo/release.json`.
24
+ * 7. record the release in `.caelo/release.json`.
19
25
  * Any failure after step 3 puts the previous Compose file back; once a
20
26
  * container was recreated the previous images are started again and
21
27
  * health-checked (rollback). The database is never restored automatically:
@@ -52,6 +58,7 @@ import {
52
58
  releaseImages,
53
59
  resolveVerifiedRelease,
54
60
  } from "./self-hosted-release.js";
61
+ import type { ConvergeOutcome } from "./self-hosted-roles.js";
55
62
  import { fetchLatestReleaseTag, RELEASE_SERVICES, releaseTag } from "./upgrade/release-images.js";
56
63
 
57
64
  /** The Compose service names of the two Caelo services. */
@@ -78,6 +85,11 @@ export interface SelfHostedUpgradeDeps {
78
85
  /** Renders the Compose file for `images` from the install's config (secrets included). */
79
86
  readonly renderCompose: (images: ReleaseImages) => string;
80
87
  readonly log: UpgradeLog;
88
+ /**
89
+ * Converges the database roles as the Postgres superuser
90
+ * (`convergeSelfHostedRoles`); drops roles a release retired (#635).
91
+ */
92
+ readonly convergeRoles: () => Promise<ConvergeOutcome>;
81
93
  readonly fetchImpl?: typeof fetch;
82
94
  readonly sleep?: (ms: number) => Promise<void>;
83
95
  readonly now?: () => Date;
@@ -116,6 +128,7 @@ type UpgradeStage =
116
128
  | "backup"
117
129
  | "pull"
118
130
  | "migrate"
131
+ | "roles"
119
132
  | "roll-admin"
120
133
  | "roll-gateway";
121
134
 
@@ -321,7 +334,19 @@ export async function upgradeSelfHosted(
321
334
  );
322
335
  }
323
336
 
324
- // 5. Roll admin, then gateway, each health-checked.
337
+ // 5. Database roles: drops what this release retired (#635 gateway_role).
338
+ log.step("Converging the database roles");
339
+ const roles = await deps.convergeRoles();
340
+ if (!roles.ok) {
341
+ restoreCompose(file, previousCompose);
342
+ return fail(
343
+ "roles",
344
+ `Could not converge the database roles: ${roles.error}\nThe previous release keeps running; the migrations are applied (expand-only). Fix the cause and re-run the upgrade. Database backup: ${backup.dir}`,
345
+ true,
346
+ );
347
+ }
348
+
349
+ // 6. Roll admin, then gateway, each health-checked.
325
350
  for (const service of RELEASE_SERVICES) {
326
351
  const stage = service === "admin" ? "roll-admin" : "roll-gateway";
327
352
  log.phase?.("roll");
@@ -353,7 +378,7 @@ export async function upgradeSelfHosted(
353
378
  log.info(`${service} healthy ✓`);
354
379
  }
355
380
 
356
- // 6. Record.
381
+ // 7. Record.
357
382
  log.phase?.("record");
358
383
  recordRelease(
359
384
  caeloDir,
@@ -74,6 +74,8 @@ describe("adminEnvContract", () => {
74
74
  CAELO_OPERATOR_ACCESS_JOB:
75
75
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
76
76
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
77
+ CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
78
+ CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
77
79
  CAELO_STATIC_BUCKET: "acme-caelo-production-static",
78
80
  CAELO_STAGING_BUCKET: "acme-caelo-production-staging",
79
81
  ...ADMIN_SECRETS,
@@ -104,6 +106,8 @@ describe("adminEnvContract", () => {
104
106
  CAELO_OPERATOR_ACCESS_JOB:
105
107
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
106
108
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
109
+ CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
110
+ CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
107
111
  CAELO_FIREBASE_SITE: "caelo-production-site-abc123",
108
112
  CAELO_GATEWAY_SERVICE: "caelo-production-gateway-1a2b3c4",
109
113
  CAELO_GATEWAY_REGION: "europe-west1",
@@ -158,12 +162,21 @@ describe("gatewayEnvContract", () => {
158
162
  CAELO_PROVIDER: "gcp",
159
163
  CAELO_ENV: "production",
160
164
  MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
165
+ // #845 — gcp: the LB appends <client>,<LB IP>.
166
+ CAELO_TRUSTED_PROXY_HOPS: "2",
161
167
  PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
162
168
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
163
169
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
164
170
  });
165
171
  });
166
172
 
173
+ it("#845 — tells the gateway its provider's proxy hop count", () => {
174
+ // Firebase Hosting / the domain mapping: Cloud Run's front end only.
175
+ const fb = asRecord(gatewayEnvContract({ ...base, provider: "gcp-firebase" }));
176
+ expect(fb.CAELO_TRUSTED_PROXY_HOPS).toBe("1");
177
+ expect(asRecord(gateway).CAELO_TRUSTED_PROXY_HOPS).toBe("2");
178
+ });
179
+
167
180
  it("#650 — reads the staging host from the database, not its env", () => {
168
181
  const fb = asRecord(gatewayEnvContract({ ...base, provider: "gcp-firebase" }));
169
182
  expect(fb.CAELO_STAGING_HOST).toBeUndefined();
@@ -223,14 +236,13 @@ describe("database role secrets (#613)", () => {
223
236
  expect(DATABASE_ROLE_SECRET).toEqual({
224
237
  admin_role: "postgres-password",
225
238
  public_role: "public-role-password",
226
- gateway_role: "gateway-role-password",
227
239
  });
228
240
  });
229
241
 
230
- it("the CLI generates the gateway's role passwords (never seeded from admin_role's)", () => {
231
- expect(CLI_GENERATED_SECRETS).toEqual(
232
- expect.arrayContaining(["public-role-password", "gateway-role-password"]),
233
- );
242
+ it("the CLI generates the gateway's role password (never seeded from admin_role's)", () => {
243
+ expect(CLI_GENERATED_SECRETS).toContain("public-role-password");
244
+ // #635 — the former gateway_role is gone; nothing generates its password.
245
+ expect(CLI_GENERATED_SECRETS as readonly string[]).not.toContain("gateway-role-password");
234
246
  });
235
247
 
236
248
  it("retires the gateway SA's read access to admin_role's password", () => {
@@ -287,11 +299,7 @@ describe("runtime secrets", () => {
287
299
  });
288
300
 
289
301
  it("every CLI-generated secret is read by some service", () => {
290
- // gateway-role-password is read by no service since #635: it only keeps
291
- // the legacy gateway_role's password set until the contract release
292
- // drops the role (the previous release's gateway logs in with it while
293
- // an upgrade rolls).
294
- for (const s of CLI_GENERATED_SECRETS.filter((x) => x !== "gateway-role-password")) {
302
+ for (const s of CLI_GENERATED_SECRETS) {
295
303
  expect([...serviceSecrets("admin"), ...serviceSecrets("gateway")]).toContain(s);
296
304
  }
297
305
  });
@@ -302,7 +310,7 @@ describe("self-hosted compose", () => {
302
310
  const yaml = generateDockerCompose({
303
311
  domain: "acme.com",
304
312
  postgresPassword: "p",
305
- rolePasswords: { admin: "a", public: "b", gateway: "g" },
313
+ rolePasswords: { admin: "a", public: "b" },
306
314
  caeloSecretKek: "k",
307
315
  diskSize: "10G",
308
316
  ports: { http: 80, https: 443 },
@@ -34,7 +34,9 @@
34
34
  * Pure module — the stacks import it from `dist/`.
35
35
  */
36
36
 
37
+ import type { SupportedProvider } from "./adapter.js";
37
38
  import {
39
+ auditJobResource,
38
40
  gatewayServiceAccountEmail,
39
41
  gcpBucketName,
40
42
  gcpNamePrefix,
@@ -168,6 +170,15 @@ export const OPERATOR_ACCESS_JOB_ENV_VAR = "CAELO_OPERATOR_ACCESS_JOB";
168
170
  */
169
171
  export const UPGRADER_JOB_ENV_VAR = "CAELO_UPGRADER_JOB";
170
172
 
173
+ /**
174
+ * #875 — the admin env vars naming the parallel quality-audit job and the
175
+ * bucket its tasks write results into (admin-core quality/
176
+ * gcp-audit-launcher.ts). `upgrade` leaves them untouched when it could not
177
+ * set the job up.
178
+ */
179
+ export const AUDIT_JOB_ENV_VAR = "CAELO_AUDIT_JOB";
180
+ export const AUDIT_RESULTS_BUCKET_ENV_VAR = "CAELO_AUDIT_RESULTS_BUCKET";
181
+
171
182
  /** Where the admin image ships the static-generator CLI. */
172
183
  const GENERATOR_CLI = "/app/apps/static-generator/src/cli.ts";
173
184
 
@@ -237,14 +248,13 @@ export function adminMediaVolumeTemplate(volume: MediaVolumeContract) {
237
248
  export type RuntimeSecret =
238
249
  | "postgres-password"
239
250
  | "public-role-password"
240
- | "gateway-role-password"
241
251
  | "secret-kek"
242
252
  | "internal-secret"
243
253
  | "tool-approval-secret"
244
254
  | "staging-gate-secret";
245
255
 
246
256
  /** The Postgres roles Caelo's services log in as, with their passwords. */
247
- export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
257
+ export type DatabaseRole = "admin_role" | "public_role";
248
258
 
249
259
  /**
250
260
  * Each database role's password secret — one per role (#613), so the
@@ -256,7 +266,6 @@ export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
256
266
  export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>> = {
257
267
  admin_role: "postgres-password",
258
268
  public_role: "public-role-password",
259
- gateway_role: "gateway-role-password",
260
269
  };
261
270
 
262
271
  /**
@@ -269,10 +278,9 @@ export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>>
269
278
  export const CLI_GENERATED_SECRETS = [
270
279
  "internal-secret",
271
280
  "tool-approval-secret",
272
- // #613 — the gateway's own role passwords. Generated (never seeded from
281
+ // #613 — the gateway's own role password. Generated (never seeded from
273
282
  // `postgres-password`), so the gateway never learns admin_role's.
274
283
  "public-role-password",
275
- "gateway-role-password",
276
284
  // #650 — the staging access gate's key: signs "Open staging" links and
277
285
  // session cookies, derives the crawler credentials. Read by the admin
278
286
  // (issues) and the gateway (verifies).
@@ -358,6 +366,42 @@ function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
358
366
  ];
359
367
  }
360
368
 
369
+ /**
370
+ * The env var telling the gateway which `X-Forwarded-For` entry is the
371
+ * visitor (apps/api-gateway client-ip.ts): the number of entries the proxies
372
+ * in front of it append, counting the client's own.
373
+ */
374
+ export const TRUSTED_PROXY_HOPS_ENV = "CAELO_TRUSTED_PROXY_HOPS";
375
+
376
+ /**
377
+ * {@link TRUSTED_PROXY_HOPS_ENV} per provider — a property of each
378
+ * adapter's topology (CLAUDE.md §11.B), so it is declared here once and every
379
+ * adapter sets it on its gateway. The gateway refuses to boot without it.
380
+ * A change to an adapter's path to the gateway must update its entry, or
381
+ * rate limits pool every visitor (too high) or trust a forgeable entry (too
382
+ * low).
383
+ */
384
+ export const GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, number>> = {
385
+ // External Application LB appends `<client>,<LB IP>`; the gateway's
386
+ // ingress is internal-and-LB only, so nothing reaches it around the LB.
387
+ gcp: 2,
388
+ // Cloud Run's front end appends its peer. Behind the Firebase Hosting
389
+ // rewrite that peer is the Hosting CDN edge, and the entry Hosting adds
390
+ // before it cannot be told apart from a forged one (the service is
391
+ // invokable on *.run.app too) — so the edge address is the most that can
392
+ // be trusted. Staging (Cloud Run domain mapping) gets the visitor.
393
+ "gcp-firebase": 1,
394
+ // Caddy without `trusted_proxies` replaces the header with its peer; the
395
+ // gateway is reachable only on the Compose network.
396
+ "self-hosted": 1,
397
+ // CloudFront appends the viewer, the ALB appends CloudFront. (No ECS task
398
+ // definition consumes it yet — see stacks/aws.)
399
+ aws: 2,
400
+ // Container Apps ingress appends its peer; the gateway's ingress is
401
+ // internal to the environment (no Front Door route to it yet).
402
+ azure: 1,
403
+ };
404
+
361
405
  /**
362
406
  * The env of the gateway service. Order is stable so the stacks see no
363
407
  * spurious diffs.
@@ -365,6 +409,7 @@ function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
365
409
  export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvVar<V | string>[] {
366
410
  return [
367
411
  ...commonEnv(inputs),
412
+ { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
368
413
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
369
414
  ...secretEnv("gateway", inputs.env),
370
415
  ];
@@ -407,6 +452,10 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
407
452
  // #696 — the job "Install update" starts (upgrader-setup.ts); the admin
408
453
  // may only run it, never override it.
409
454
  { name: UPGRADER_JOB_ENV_VAR, value: upgraderJobResource(projectId, inputs.region, env) },
455
+ // #875 — the job the quality audit fans its Lighthouse runs out to, and
456
+ // where the tasks write their results (audit-job-setup.ts).
457
+ { name: AUDIT_JOB_ENV_VAR, value: auditJobResource(projectId, inputs.region, env) },
458
+ { name: AUDIT_RESULTS_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "audit-results") },
410
459
  ];
411
460
  if (inputs.provider === "gcp") {
412
461
  // v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
@@ -344,6 +344,8 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
344
344
  "ADMIN_DATABASE_URL",
345
345
  // #650 — the staging gate key arrives in the same roll.
346
346
  "CAELO_STAGING_GATE_SECRET",
347
+ // #845 — and which forwarded address is the visitor.
348
+ "CAELO_TRUSTED_PROXY_HOPS",
347
349
  "PUBLIC_DATABASE_PASSWORD",
348
350
  ]);
349
351
  });
@@ -312,10 +312,12 @@ describe("planContractEnv", () => {
312
312
  // #650 — the same roll converges the protected staging domain's host
313
313
  // and gate key onto an install that predates it.
314
314
  expect(plan.services.admin.flags).toEqual([
315
- "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_STAGING_HOST=staging.acme.com",
315
+ "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_STAGING_HOST=staging.acme.com",
316
316
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
317
317
  ]);
318
318
  expect(plan.services.gateway.flags).toEqual([
319
+ // The gateway learns which forwarded address is the visitor (#845).
320
+ "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1",
319
321
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
320
322
  ]);
321
323
  });
@@ -35,18 +35,160 @@ describe("listTarEntries", () => {
35
35
  });
36
36
 
37
37
  it("refuses a truncated archive instead of skipping members", () => {
38
- const header = new Uint8Array(512);
39
- header.set(new TextEncoder().encode("a.txt"), 0);
40
- header.set(new TextEncoder().encode("00000001000\0"), 124); // 512 bytes of data, none present
41
- header[156] = "0".charCodeAt(0);
42
- expect(() => listTarEntries(header)).toThrow(/truncated/);
38
+ expect(() => listTarEntries(header("a.txt", "0", { sizeField: 512 }))).toThrow(/truncated/);
39
+ });
40
+
41
+ it("rejects PAX global extended headers ('g')", () => {
42
+ const g = member("PaxHeader", "g", paxLine("path=/etc/passwd"));
43
+ expect(() => listTarEntries(concat(g, member("safe.txt", "0"), END))).toThrow(
44
+ /pax global extended header/,
45
+ );
46
+ });
47
+
48
+ it("refuses a member named by two pax headers — GNU tar keeps the last, bsdtar drops the second", () => {
49
+ // Both tars extract `../../evil` here (the second header carries no
50
+ // path); reading the first header's path would have checked `safe.txt`.
51
+ const archive = concat(
52
+ member("PaxHeader", "x", paxLine("path=srv/caelo/safe.txt")),
53
+ member("PaxHeader", "x", paxLine("mtime=1")),
54
+ member("../../evil", "0"),
55
+ END,
56
+ );
57
+ expect(() => listTarEntries(archive)).toThrow(/several extension headers \(xx\)/);
58
+ });
59
+
60
+ it("refuses a GNU long name next to a pax path, and two long names", () => {
61
+ // bsdtar extracts the `L` name (`../../evil`), GNU tar the pax path.
62
+ const mixed = concat(
63
+ member("././@LongLink", "L", "../../evil\0"),
64
+ member("PaxHeader", "x", paxLine("path=srv/caelo/safe.txt")),
65
+ member("x", "0"),
66
+ END,
67
+ );
68
+ expect(() => listTarEntries(mixed)).toThrow(/several extension headers \(Lx\)/);
69
+ const twice = concat(
70
+ member("././@LongLink", "L", "../../evil\0"),
71
+ member("././@LongLink", "L", "safe.txt\0"),
72
+ member("x", "0"),
73
+ END,
74
+ );
75
+ expect(() => listTarEntries(twice)).toThrow(/several extension headers \(LL\)/);
76
+ });
77
+
78
+ it("still reads a GNU long name together with a long link", () => {
79
+ const archive = concat(
80
+ member("././@LongLink", "K", "target\0"),
81
+ member("././@LongLink", "L", `${"a".repeat(120)}\0`),
82
+ member("short", "2", "", { link: "ignored" }),
83
+ END,
84
+ );
85
+ expect(listTarEntries(archive)).toEqual([
86
+ { name: "a".repeat(120), type: "symlink", linkTarget: "target" },
87
+ ]);
88
+ });
89
+
90
+ it("refuses a header whose checksum does not match — GNU tar would skip it and read its body as headers", () => {
91
+ const archive = concat(
92
+ header("srv/caelo/safe.txt", "0", { sizeField: 512, badChecksum: true }),
93
+ header("../../evil", "0"),
94
+ END,
95
+ );
96
+ expect(() => listTarEntries(archive)).toThrow(/checksum mismatch/);
97
+ });
98
+
99
+ it("refuses a directory or symlink with a body — bsdtar reads the next block as a header", () => {
100
+ for (const [name, type] of [
101
+ ["srv/caelo/link", "2"],
102
+ ["srv/caelo/dir/", "5"],
103
+ ] as const) {
104
+ const archive = concat(
105
+ header(name, type, { sizeField: 512, link: "a" }),
106
+ header("../../evil", "0"),
107
+ END,
108
+ );
109
+ expect(() => listTarEntries(archive)).toThrow(/with a body/);
110
+ }
111
+ });
112
+
113
+ it("walks a member's body by its pax size, so a member hidden behind the header size is seen", () => {
114
+ // tar -x reads `safe.txt` as empty (pax size=0) and then extracts the
115
+ // `../../evil` header that the ustar size field claims is file data.
116
+ const archive = concat(
117
+ member("PaxHeader", "x", paxLine("size=0")),
118
+ header("safe.txt", "0", { sizeField: 512 }),
119
+ member("../../evil", "0"),
120
+ END,
121
+ );
122
+ expect(listTarEntries(archive).map((e) => e.name)).toEqual(["safe.txt", "../../evil"]);
123
+ });
124
+
125
+ it("rejects GNU sparse pax records and an unreadable pax size", () => {
126
+ const sparse = member("PaxHeader", "x", paxLine("GNU.sparse.name=../../evil"));
127
+ expect(() => listTarEntries(concat(sparse, member("safe.txt", "0")))).toThrow(/GNU sparse/);
128
+ const badSize = member("PaxHeader", "x", paxLine("size=-1"));
129
+ expect(() => listTarEntries(concat(badSize, member("safe.txt", "0")))).toThrow(
130
+ /unreadable pax size/,
131
+ );
43
132
  });
44
133
  });
45
134
 
46
- describe("escapesRoot", () => {
47
- it("flags absolute and parent-relative member names", () => {
135
+ const END = new Uint8Array(1024);
136
+
137
+ /**
138
+ * One ustar header block with a valid checksum (unless `badChecksum`).
139
+ * `sizeField` is what the header claims, independent of any body after it.
140
+ */
141
+ function header(
142
+ name: string,
143
+ typeflag: string,
144
+ opts: { sizeField?: number; link?: string; badChecksum?: boolean } = {},
145
+ ): Uint8Array {
146
+ const enc = new TextEncoder();
147
+ const h = new Uint8Array(512);
148
+ h.set(enc.encode(name), 0);
149
+ h.set(enc.encode(`${(opts.sizeField ?? 0).toString(8).padStart(11, "0")}\0`), 124);
150
+ h[156] = typeflag.charCodeAt(0);
151
+ if (opts.link) h.set(enc.encode(opts.link), 157);
152
+ h.set(enc.encode("ustar\0"), 257);
153
+ h.fill(0x20, 148, 156);
154
+ const sum = h.reduce((n, b) => n + b, 0) + (opts.badChecksum ? 1 : 0);
155
+ h.set(enc.encode(`${sum.toString(8).padStart(6, "0")}\0 `), 148);
156
+ return h;
157
+ }
158
+
159
+ /** A header plus its padded body. */
160
+ function member(name: string, typeflag: string, bodyText = "", opts: { link?: string } = {}) {
161
+ const body = new TextEncoder().encode(bodyText);
162
+ const out = new Uint8Array(512 + Math.ceil(body.length / 512) * 512);
163
+ out.set(header(name, typeflag, { sizeField: body.length, link: opts.link }), 0);
164
+ out.set(body, 512);
165
+ return out;
166
+ }
167
+
168
+ /** One pax record; its leading length counts its own digits. */
169
+ function paxLine(kv: string): string {
170
+ const base = ` ${kv}\n`;
171
+ const len = String(base.length + 2).length + base.length;
172
+ return `${len}${base}`;
173
+ }
174
+
175
+ function concat(...parts: Uint8Array[]): Uint8Array {
176
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
177
+ let at = 0;
178
+ for (const p of parts) {
179
+ out.set(p, at);
180
+ at += p.length;
181
+ }
182
+ return out;
183
+ }
184
+
185
+ describe("escapesRoot & normalizeMemberName", () => {
186
+ it("flags absolute, parent-relative, and dot-segment member names", () => {
48
187
  expect(escapesRoot("/etc/passwd")).toBe(true);
49
188
  expect(escapesRoot("srv/caelo/../../app")).toBe(true);
189
+ expect(escapesRoot(".//../etc/passwd")).toBe(true);
190
+ expect(escapesRoot("a/./b")).toBe(true);
50
191
  expect(escapesRoot("./srv/caelo/a..b")).toBe(false);
192
+ expect(normalizeMemberName(".//srv//caelo/")).toBe("srv/caelo");
51
193
  });
52
194
  });
@@ -7,12 +7,34 @@
7
7
  * otherwise land outside the target directory).
8
8
  *
9
9
  * Parses ustar headers plus the two extensions GNU tar and bsdtar emit for
10
- * long names: GNU `L`/`K` records and pax `x`/`g` records (`path=`,
11
- * `linkpath=`). Anything it cannot read is an error, never a skipped member.
10
+ * long names: GNU `L`/`K` records and pax `x` records (`path=`, `linkpath=`,
11
+ * `size=`). Anything it cannot read is an error, never a skipped member.
12
+ *
13
+ * The parser must walk the archive exactly as `tar -x` will: a pax `size=`
14
+ * overrides the header's size field for the next member, so ignoring it
15
+ * would let an archive hide a member from this check inside what the parser
16
+ * takes for a body. Records that rename a member in ways it does not model
17
+ * (pax global `g` headers, GNU sparse `GNU.sparse.*` keys) are refused.
18
+ *
19
+ * Wherever GNU tar and bsdtar read the same bytes differently, the archive is
20
+ * refused rather than read one way: no Caelo backup is ever written like that,
21
+ * and whichever reading this module picked, the other tar would extract
22
+ * something it never checked.
23
+ * - A header whose checksum does not match: GNU tar skips it and reads the
24
+ * next block as a header, so a member hidden in the "body" gets extracted.
25
+ * - A directory or symlink with a non-zero size: bsdtar reads no body for
26
+ * them, so the block after the header is the next member's header.
27
+ * - Two name records for one member (two pax `x` headers, two `L`/`K`, or
28
+ * `L`/`K` next to `x`): GNU tar keeps only the last `x` header, bsdtar
29
+ * flags the second as malformed and drops it, and the two tars disagree
30
+ * on whether a GNU long name or a pax path wins.
12
31
  */
13
32
 
14
33
  const BLOCK = 512;
15
34
 
35
+ /** The extension headers one member may carry: one pax record, or a GNU long name and/or long link. */
36
+ const SINGLE_NAMING = new Set(["x", "L", "K", "LK", "KL"]);
37
+
16
38
  export type TarEntryType = "file" | "directory" | "symlink" | "other";
17
39
 
18
40
  export interface TarEntry {
@@ -30,15 +52,34 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
30
52
  let longLink: string | null = null;
31
53
  let paxPath: string | null = null;
32
54
  let paxLink: string | null = null;
55
+ let paxSize: number | null = null;
56
+ // The extension headers already seen for the member being read.
57
+ let extensions = "";
33
58
  while (offset + BLOCK <= tar.length) {
34
59
  const header = tar.subarray(offset, offset + BLOCK);
35
60
  if (header.every((b) => b === 0)) break; // end-of-archive marker
36
- const size = octal(header.subarray(124, 136));
61
+ verifyChecksum(header);
37
62
  const typeflag = String.fromCharCode(header[156] ?? 0);
63
+ // A pax `size=` describes the member that follows the pax header, never
64
+ // another extension header (GNU tar applies it the same way).
65
+ const isExtension =
66
+ typeflag === "x" || typeflag === "g" || typeflag === "L" || typeflag === "K";
67
+ const size = !isExtension && paxSize !== null ? paxSize : octal(header.subarray(124, 136));
38
68
  const body = tar.subarray(offset + BLOCK, offset + BLOCK + size);
39
69
  if (body.length < size) throw new Error("truncated tar archive");
40
70
  offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
41
71
 
72
+ if (typeflag === "g") {
73
+ throw new Error("tar archive contains unsupported pax global extended header");
74
+ }
75
+ if (isExtension) {
76
+ extensions += typeflag;
77
+ if (!SINGLE_NAMING.has(extensions)) {
78
+ throw new Error(
79
+ `tar archive names one member through several extension headers (${extensions}) — tar implementations disagree on which one wins`,
80
+ );
81
+ }
82
+ }
42
83
  if (typeflag === "L") {
43
84
  longName = cString(body);
44
85
  continue;
@@ -47,11 +88,18 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
47
88
  longLink = cString(body);
48
89
  continue;
49
90
  }
50
- if (typeflag === "x" || typeflag === "g") {
91
+ if (typeflag === "x") {
51
92
  const pax = paxRecords(body);
52
- if (typeflag === "x") {
53
- paxPath = pax.path ?? null;
54
- paxLink = pax.linkpath ?? null;
93
+ if (Object.keys(pax).some((key) => key.startsWith("GNU.sparse."))) {
94
+ throw new Error("tar archive contains an unsupported GNU sparse member");
95
+ }
96
+ paxPath = pax.path ?? null;
97
+ paxLink = pax.linkpath ?? null;
98
+ if (pax.size !== undefined) {
99
+ if (!/^[0-9]+$/.test(pax.size)) {
100
+ throw new Error(`unreadable pax size ${JSON.stringify(pax.size)}`);
101
+ }
102
+ paxSize = Number.parseInt(pax.size, 10);
55
103
  }
56
104
  continue;
57
105
  }
@@ -60,6 +108,8 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
60
108
  const name = paxPath ?? longName ?? (prefix ? `${prefix}/${shortName}` : shortName);
61
109
  const link = paxLink ?? longLink ?? cString(header.subarray(157, 257));
62
110
  longName = longLink = paxPath = paxLink = null;
111
+ paxSize = null;
112
+ extensions = "";
63
113
  const type: TarEntryType =
64
114
  typeflag === "0" || typeflag === "\0" || typeflag === "7"
65
115
  ? "file"
@@ -68,6 +118,9 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
68
118
  : typeflag === "2"
69
119
  ? "symlink"
70
120
  : "other";
121
+ if (type !== "file" && size !== 0) {
122
+ throw new Error(`tar member ${JSON.stringify(name)} is a ${type} with a body`);
123
+ }
71
124
  entries.push(type === "symlink" ? { name, type, linkTarget: link } : { name, type });
72
125
  }
73
126
  return entries;
@@ -75,12 +128,40 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
75
128
 
76
129
  /** `./a/b/` → `a/b`; the archive root (`./`) → "". */
77
130
  export function normalizeMemberName(name: string): string {
78
- return name.replace(/^(\.\/)+/, "").replace(/\/+$/, "");
131
+ return name
132
+ .replace(/\/+/g, "/")
133
+ .replace(/^(\.\/)+/, "")
134
+ .replace(/\/+$/, "");
79
135
  }
80
136
 
81
137
  /** Whether a member name could write outside the extraction directory. */
82
138
  export function escapesRoot(name: string): boolean {
83
- return name.startsWith("/") || name.split("/").some((part) => part === "..");
139
+ const normalized = name.replace(/\/+/g, "/").replace(/^(\.\/)+/, "");
140
+ return (
141
+ name.startsWith("/") || normalized.split("/").some((part) => part === ".." || part === ".")
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Refuse a header whose checksum does not match, as `tar -x` would — except
147
+ * GNU tar then goes on reading at the next block, which this check must not.
148
+ * The sum counts the checksum field as spaces; old tars summed signed bytes,
149
+ * which GNU tar and bsdtar both still accept.
150
+ */
151
+ function verifyChecksum(header: Uint8Array): void {
152
+ const recorded = cString(header.subarray(148, 156)).trim();
153
+ if (!/^[0-7]+$/.test(recorded)) throw new Error("unreadable tar header checksum");
154
+ let unsigned = 0;
155
+ let signed = 0;
156
+ for (let i = 0; i < BLOCK; i++) {
157
+ const b = i >= 148 && i < 156 ? 0x20 : (header[i] ?? 0);
158
+ unsigned += b;
159
+ signed += b > 127 ? b - 256 : b;
160
+ }
161
+ const expected = Number.parseInt(recorded, 8);
162
+ if (expected !== unsigned && expected !== signed) {
163
+ throw new Error("tar header checksum mismatch — the archive is damaged or was altered");
164
+ }
84
165
  }
85
166
 
86
167
  function octal(field: Uint8Array): number {