@caelo-cms/provisioning 0.11.9 → 0.11.11

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 (352) hide show
  1. package/dist/adapter.d.ts +6 -0
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/admin-domain.d.ts +10 -1
  4. package/dist/admin-domain.d.ts.map +1 -1
  5. package/dist/admin-domain.js +13 -3
  6. package/dist/admin-domain.js.map +1 -1
  7. package/dist/adopt-existing.d.ts +12 -3
  8. package/dist/adopt-existing.d.ts.map +1 -1
  9. package/dist/adopt-existing.js +143 -0
  10. package/dist/adopt-existing.js.map +1 -1
  11. package/dist/agent/command.d.ts +23 -0
  12. package/dist/agent/command.d.ts.map +1 -1
  13. package/dist/agent/command.js +32 -9
  14. package/dist/agent/command.js.map +1 -1
  15. package/dist/agent/run.d.ts +2 -0
  16. package/dist/agent/run.d.ts.map +1 -1
  17. package/dist/agent/run.js +5 -1
  18. package/dist/agent/run.js.map +1 -1
  19. package/dist/agent-db.js +69 -10
  20. package/dist/aws-task-roles.d.ts +4 -2
  21. package/dist/aws-task-roles.d.ts.map +1 -1
  22. package/dist/aws-task-roles.js +4 -1
  23. package/dist/aws-task-roles.js.map +1 -1
  24. package/dist/caddy.d.ts +21 -4
  25. package/dist/caddy.d.ts.map +1 -1
  26. package/dist/caddy.js +27 -3
  27. package/dist/caddy.js.map +1 -1
  28. package/dist/cli-routing.js +1 -1
  29. package/dist/cli-routing.js.map +1 -1
  30. package/dist/cli.js +68 -7
  31. package/dist/cli.js.map +1 -1
  32. package/dist/cloudsql-tls.d.ts +93 -0
  33. package/dist/cloudsql-tls.d.ts.map +1 -0
  34. package/dist/cloudsql-tls.js +295 -0
  35. package/dist/cloudsql-tls.js.map +1 -0
  36. package/dist/compose.d.ts +43 -6
  37. package/dist/compose.d.ts.map +1 -1
  38. package/dist/compose.js +148 -23
  39. package/dist/compose.js.map +1 -1
  40. package/dist/converge-checkpoint.d.ts +1 -0
  41. package/dist/converge-checkpoint.d.ts.map +1 -1
  42. package/dist/converge-checkpoint.js +8 -1
  43. package/dist/converge-checkpoint.js.map +1 -1
  44. package/dist/converge-db-trust.d.ts +44 -0
  45. package/dist/converge-db-trust.d.ts.map +1 -0
  46. package/dist/converge-db-trust.js +158 -0
  47. package/dist/converge-db-trust.js.map +1 -0
  48. package/dist/converge-job-gcp.d.ts +5 -2
  49. package/dist/converge-job-gcp.d.ts.map +1 -1
  50. package/dist/converge-job-gcp.js +11 -13
  51. package/dist/converge-job-gcp.js.map +1 -1
  52. package/dist/converge-job-preflight.d.ts.map +1 -1
  53. package/dist/converge-job-preflight.js +8 -0
  54. package/dist/converge-job-preflight.js.map +1 -1
  55. package/dist/converge-job.d.ts +17 -0
  56. package/dist/converge-job.d.ts.map +1 -1
  57. package/dist/converge-job.js +12 -0
  58. package/dist/converge-job.js.map +1 -1
  59. package/dist/deployed-release.d.ts +4 -1
  60. package/dist/deployed-release.d.ts.map +1 -1
  61. package/dist/deployed-release.js +18 -8
  62. package/dist/deployed-release.js.map +1 -1
  63. package/dist/gcp-names.d.ts +34 -0
  64. package/dist/gcp-names.d.ts.map +1 -1
  65. package/dist/gcp-names.js +45 -0
  66. package/dist/gcp-names.js.map +1 -1
  67. package/dist/gcp-url-map.d.ts.map +1 -1
  68. package/dist/gcp-url-map.js +6 -2
  69. package/dist/gcp-url-map.js.map +1 -1
  70. package/dist/index.d.ts +3 -2
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +2 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/infra-contract.d.ts +1 -1
  75. package/dist/infra-contract.d.ts.map +1 -1
  76. package/dist/infra-contract.js +45 -1
  77. package/dist/infra-contract.js.map +1 -1
  78. package/dist/install-job.d.ts +4 -2
  79. package/dist/install-job.d.ts.map +1 -1
  80. package/dist/install-job.js +4 -1
  81. package/dist/install-job.js.map +1 -1
  82. package/dist/install-state.d.ts +13 -0
  83. package/dist/install-state.d.ts.map +1 -1
  84. package/dist/install-state.js +17 -4
  85. package/dist/install-state.js.map +1 -1
  86. package/dist/lifecycle.d.ts.map +1 -1
  87. package/dist/lifecycle.js +20 -8
  88. package/dist/lifecycle.js.map +1 -1
  89. package/dist/mcp-auth-proxy-setup.d.ts +48 -0
  90. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
  91. package/dist/mcp-auth-proxy-setup.js +280 -0
  92. package/dist/mcp-auth-proxy-setup.js.map +1 -0
  93. package/dist/migration-runner.d.ts.map +1 -1
  94. package/dist/migration-runner.js +8 -3
  95. package/dist/migration-runner.js.map +1 -1
  96. package/dist/migration-targets.d.ts +11 -0
  97. package/dist/migration-targets.d.ts.map +1 -0
  98. package/dist/migration-targets.js +12 -0
  99. package/dist/migration-targets.js.map +1 -0
  100. package/dist/operator-access.d.ts +10 -0
  101. package/dist/operator-access.d.ts.map +1 -1
  102. package/dist/operator-access.js +5 -0
  103. package/dist/operator-access.js.map +1 -1
  104. package/dist/pool-budget-stack.d.ts +13 -1
  105. package/dist/pool-budget-stack.d.ts.map +1 -1
  106. package/dist/pool-budget-stack.js +11 -1
  107. package/dist/pool-budget-stack.js.map +1 -1
  108. package/dist/public-role-isolation.d.ts +7 -3
  109. package/dist/public-role-isolation.d.ts.map +1 -1
  110. package/dist/public-role-isolation.js +25 -13
  111. package/dist/public-role-isolation.js.map +1 -1
  112. package/dist/release-contract.d.ts.map +1 -1
  113. package/dist/release-contract.js +3 -0
  114. package/dist/release-contract.js.map +1 -1
  115. package/dist/release-manifest.d.ts +25 -1
  116. package/dist/release-manifest.d.ts.map +1 -1
  117. package/dist/release-manifest.js +36 -3
  118. package/dist/release-manifest.js.map +1 -1
  119. package/dist/remote-mcp.d.ts +55 -18
  120. package/dist/remote-mcp.d.ts.map +1 -1
  121. package/dist/remote-mcp.js +307 -77
  122. package/dist/remote-mcp.js.map +1 -1
  123. package/dist/runtime-secrets.d.ts +11 -0
  124. package/dist/runtime-secrets.d.ts.map +1 -1
  125. package/dist/runtime-secrets.js +40 -25
  126. package/dist/runtime-secrets.js.map +1 -1
  127. package/dist/scheduler-setup.d.ts +75 -0
  128. package/dist/scheduler-setup.d.ts.map +1 -0
  129. package/dist/scheduler-setup.js +331 -0
  130. package/dist/scheduler-setup.js.map +1 -0
  131. package/dist/seal-key-rotation.d.ts +7 -7
  132. package/dist/seal-key-rotation.d.ts.map +1 -1
  133. package/dist/seal-key-rotation.js +22 -22
  134. package/dist/seal-key-rotation.js.map +1 -1
  135. package/dist/self-hosted-db-tls.d.ts +37 -0
  136. package/dist/self-hosted-db-tls.d.ts.map +1 -0
  137. package/dist/self-hosted-db-tls.js +220 -0
  138. package/dist/self-hosted-db-tls.js.map +1 -0
  139. package/dist/self-hosted-release.d.ts +3 -1
  140. package/dist/self-hosted-release.d.ts.map +1 -1
  141. package/dist/self-hosted-release.js +33 -9
  142. package/dist/self-hosted-release.js.map +1 -1
  143. package/dist/self-hosted-roles.d.ts +43 -2
  144. package/dist/self-hosted-roles.d.ts.map +1 -1
  145. package/dist/self-hosted-roles.js +87 -1
  146. package/dist/self-hosted-roles.js.map +1 -1
  147. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  148. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  149. package/dist/self-hosted-scheduler-interval.js +68 -0
  150. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  151. package/dist/self-hosted-start.d.ts +0 -21
  152. package/dist/self-hosted-start.d.ts.map +1 -1
  153. package/dist/self-hosted-start.js +39 -1
  154. package/dist/self-hosted-start.js.map +1 -1
  155. package/dist/self-hosted-upgrade.d.ts +19 -7
  156. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  157. package/dist/self-hosted-upgrade.js +78 -25
  158. package/dist/self-hosted-upgrade.js.map +1 -1
  159. package/dist/stack-contract.d.ts +234 -28
  160. package/dist/stack-contract.d.ts.map +1 -1
  161. package/dist/stack-contract.js +397 -39
  162. package/dist/stack-contract.js.map +1 -1
  163. package/dist/stack-converge.d.ts +31 -0
  164. package/dist/stack-converge.d.ts.map +1 -1
  165. package/dist/stack-converge.js +71 -30
  166. package/dist/stack-converge.js.map +1 -1
  167. package/dist/staging-database.d.ts +22 -0
  168. package/dist/staging-database.d.ts.map +1 -0
  169. package/dist/staging-database.js +117 -0
  170. package/dist/staging-database.js.map +1 -0
  171. package/dist/upgrade/app-roll.d.ts +25 -4
  172. package/dist/upgrade/app-roll.d.ts.map +1 -1
  173. package/dist/upgrade/app-roll.js +63 -17
  174. package/dist/upgrade/app-roll.js.map +1 -1
  175. package/dist/upgrade/converge.d.ts +5 -0
  176. package/dist/upgrade/converge.d.ts.map +1 -1
  177. package/dist/upgrade/converge.js +207 -20
  178. package/dist/upgrade/converge.js.map +1 -1
  179. package/dist/upgrade/health.d.ts +31 -7
  180. package/dist/upgrade/health.d.ts.map +1 -1
  181. package/dist/upgrade/health.js +41 -17
  182. package/dist/upgrade/health.js.map +1 -1
  183. package/dist/upgrade/image-forms.d.ts +1 -1
  184. package/dist/upgrade/image-forms.d.ts.map +1 -1
  185. package/dist/upgrade/image-forms.js +24 -7
  186. package/dist/upgrade/image-forms.js.map +1 -1
  187. package/dist/upgrade/release-images.d.ts +12 -3
  188. package/dist/upgrade/release-images.d.ts.map +1 -1
  189. package/dist/upgrade/release-images.js +13 -4
  190. package/dist/upgrade/release-images.js.map +1 -1
  191. package/dist/upgrade/upgrade.d.ts +19 -3
  192. package/dist/upgrade/upgrade.d.ts.map +1 -1
  193. package/dist/upgrade/upgrade.js +37 -2
  194. package/dist/upgrade/upgrade.js.map +1 -1
  195. package/dist/upgrader-gcp.d.ts +13 -0
  196. package/dist/upgrader-gcp.d.ts.map +1 -1
  197. package/dist/upgrader-gcp.js +50 -1
  198. package/dist/upgrader-gcp.js.map +1 -1
  199. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  200. package/dist/upgrader-iam-preflight.js +6 -1
  201. package/dist/upgrader-iam-preflight.js.map +1 -1
  202. package/dist/upgrader-job.d.ts +1 -1
  203. package/dist/upgrader-job.d.ts.map +1 -1
  204. package/dist/upgrader-job.js +20 -14
  205. package/dist/upgrader-job.js.map +1 -1
  206. package/dist/upgrader-records.d.ts +1 -1
  207. package/dist/upgrader-records.d.ts.map +1 -1
  208. package/dist/upgrader-records.js +26 -7
  209. package/dist/upgrader-records.js.map +1 -1
  210. package/dist/upgrader-setup.d.ts.map +1 -1
  211. package/dist/upgrader-setup.js +10 -5
  212. package/dist/upgrader-setup.js.map +1 -1
  213. package/dist/wizard.d.ts +7 -0
  214. package/dist/wizard.d.ts.map +1 -1
  215. package/dist/wizard.js +11 -0
  216. package/dist/wizard.js.map +1 -1
  217. package/dist/wizards/gcp-cost.d.ts +31 -0
  218. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  219. package/dist/wizards/gcp-cost.js +103 -18
  220. package/dist/wizards/gcp-cost.js.map +1 -1
  221. package/dist/wizards/gcp.d.ts.map +1 -1
  222. package/dist/wizards/gcp.js +129 -27
  223. package/dist/wizards/gcp.js.map +1 -1
  224. package/package.json +2 -2
  225. package/src/adapter.ts +6 -0
  226. package/src/admin-domain.test.ts +9 -8
  227. package/src/admin-domain.ts +13 -3
  228. package/src/admin-memory.test.ts +2 -1
  229. package/src/adopt-existing.test.ts +85 -0
  230. package/src/adopt-existing.ts +141 -3
  231. package/src/agent/command.test.ts +49 -1
  232. package/src/agent/command.ts +39 -9
  233. package/src/agent/run.test.ts +19 -0
  234. package/src/agent/run.ts +11 -2
  235. package/src/aws-task-roles.ts +5 -2
  236. package/src/caddy-redirects.docker.test.ts +2 -0
  237. package/src/caddy-redirects.test.ts +4 -1
  238. package/src/caddy-validate.test.ts +7 -1
  239. package/src/caddy.test.ts +39 -1
  240. package/src/caddy.ts +36 -4
  241. package/src/checkpoint-secrets.test.ts +14 -6
  242. package/src/cli-routing.test.ts +1 -1
  243. package/src/cli-routing.ts +1 -1
  244. package/src/cli.ts +71 -5
  245. package/src/cloudsql-tls.test.ts +255 -0
  246. package/src/cloudsql-tls.ts +359 -0
  247. package/src/compose.ts +193 -24
  248. package/src/converge-checkpoint.ts +8 -1
  249. package/src/converge-db-trust.test.ts +237 -0
  250. package/src/converge-db-trust.ts +212 -0
  251. package/src/converge-job-gcp.ts +11 -12
  252. package/src/converge-job-preflight.test.ts +5 -0
  253. package/src/converge-job-preflight.ts +8 -0
  254. package/src/converge-job.test.ts +70 -0
  255. package/src/converge-job.ts +32 -0
  256. package/src/database-tls-builders.test.ts +56 -0
  257. package/src/deployed-release.test.ts +107 -40
  258. package/src/deployed-release.ts +30 -9
  259. package/src/gateway-env-isolation.test.ts +50 -12
  260. package/src/gcp-names.ts +56 -0
  261. package/src/gcp-url-map.test.ts +6 -8
  262. package/src/gcp-url-map.ts +6 -2
  263. package/src/index.test.ts +10 -4
  264. package/src/index.ts +7 -0
  265. package/src/infra-contract.test.ts +7 -3
  266. package/src/infra-contract.ts +45 -1
  267. package/src/install-job.ts +4 -1
  268. package/src/install-state.ts +33 -4
  269. package/src/lifecycle.ts +23 -11
  270. package/src/mcp-auth-proxy-setup.test.ts +305 -0
  271. package/src/mcp-auth-proxy-setup.ts +374 -0
  272. package/src/mcp-iap-stacks.test.ts +1 -1
  273. package/src/media-volume.test.ts +1 -1
  274. package/src/migration-runner.ts +14 -9
  275. package/src/migration-targets.ts +12 -0
  276. package/src/operator-access-stacks.test.ts +1 -1
  277. package/src/operator-access.test.ts +20 -4
  278. package/src/operator-access.ts +14 -0
  279. package/src/pool-budget-stack.ts +15 -0
  280. package/src/pool-budget-stacks.test.ts +39 -14
  281. package/src/public-role-isolation.test.ts +18 -8
  282. package/src/public-role-isolation.ts +31 -17
  283. package/src/release-contract.ts +3 -0
  284. package/src/release-manifest.test.ts +25 -0
  285. package/src/release-manifest.ts +59 -4
  286. package/src/remote-mcp-stack-config.test.ts +19 -9
  287. package/src/remote-mcp.test.ts +252 -57
  288. package/src/remote-mcp.ts +336 -77
  289. package/src/runtime-secrets.test.ts +37 -14
  290. package/src/runtime-secrets.ts +87 -27
  291. package/src/scheduler-setup.test.ts +201 -0
  292. package/src/scheduler-setup.ts +375 -0
  293. package/src/seal-key-rotation.test.ts +2 -2
  294. package/src/seal-key-rotation.ts +25 -25
  295. package/src/self-hosted-backup.test.ts +1 -0
  296. package/src/self-hosted-compose.test.ts +180 -6
  297. package/src/self-hosted-db-tls.test.ts +111 -0
  298. package/src/self-hosted-db-tls.ts +287 -0
  299. package/src/self-hosted-release-resolve.test.ts +71 -0
  300. package/src/self-hosted-release.ts +41 -11
  301. package/src/self-hosted-roles.integration.test.ts +2 -0
  302. package/src/self-hosted-roles.test.ts +87 -3
  303. package/src/self-hosted-roles.ts +101 -3
  304. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  305. package/src/self-hosted-scheduler-interval.ts +79 -0
  306. package/src/self-hosted-start.test.ts +35 -2
  307. package/src/self-hosted-start.ts +20 -2
  308. package/src/self-hosted-upgrade.test.ts +104 -14
  309. package/src/self-hosted-upgrade.ts +123 -31
  310. package/src/stack-contract-parity.test.ts +79 -12
  311. package/src/stack-contract.test.ts +199 -31
  312. package/src/stack-contract.ts +472 -45
  313. package/src/stack-converge-secrets.test.ts +28 -13
  314. package/src/stack-converge.test.ts +203 -28
  315. package/src/stack-converge.ts +92 -30
  316. package/src/staging-database.test.ts +114 -0
  317. package/src/staging-database.ts +148 -0
  318. package/src/state-store.test.ts +2 -1
  319. package/src/upgrade/app-roll.ts +97 -19
  320. package/src/upgrade/converge.ts +249 -19
  321. package/src/upgrade/health.test.ts +63 -25
  322. package/src/upgrade/health.ts +54 -22
  323. package/src/upgrade/image-forms.test.ts +34 -0
  324. package/src/upgrade/image-forms.ts +23 -8
  325. package/src/upgrade/release-images.test.ts +5 -1
  326. package/src/upgrade/release-images.ts +22 -6
  327. package/src/upgrade/upgrade.test.ts +438 -46
  328. package/src/upgrade/upgrade.ts +64 -6
  329. package/src/upgrader-gcp.ts +66 -3
  330. package/src/upgrader-iam-preflight.test.ts +19 -0
  331. package/src/upgrader-iam-preflight.ts +6 -0
  332. package/src/upgrader-job.test.ts +101 -27
  333. package/src/upgrader-job.ts +27 -14
  334. package/src/upgrader-records.test.ts +97 -12
  335. package/src/upgrader-records.ts +28 -11
  336. package/src/upgrader-setup.test.ts +2 -0
  337. package/src/upgrader-setup.ts +10 -4
  338. package/src/wizard.ts +19 -0
  339. package/src/wizards/gcp-cost.test.ts +103 -0
  340. package/src/wizards/gcp-cost.ts +125 -18
  341. package/src/wizards/gcp.ts +164 -22
  342. package/stacks/aws/Pulumi.yaml +1 -1
  343. package/stacks/aws/index.ts +72 -14
  344. package/stacks/azure/index.ts +108 -21
  345. package/stacks/gcp/Pulumi.yaml +1 -1
  346. package/stacks/gcp/README.md +1 -0
  347. package/stacks/gcp/index.ts +133 -24
  348. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  349. package/stacks/gcp-firebase/index.ts +212 -34
  350. package/stacks/self-hosted/Pulumi.yaml +3 -0
  351. package/stacks/self-hosted/README.md +6 -1
  352. package/stacks/self-hosted/index.ts +40 -5
@@ -6,7 +6,11 @@ import {
6
6
  COMPOSE_ADMIN_UPSTREAM,
7
7
  COMPOSE_APP_SERVICES,
8
8
  COMPOSE_GATEWAY_UPSTREAM,
9
+ COMPOSE_MCP_AUTH_PROXY_SERVICE,
10
+ COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
9
11
  COMPOSE_POOL_BUDGET,
12
+ COMPOSE_SCHEDULER_SERVICE,
13
+ COMPOSE_SCHEDULER_TICK_URL,
10
14
  type ComposeSpec,
11
15
  composeProjectName,
12
16
  generateDockerCompose,
@@ -20,8 +24,9 @@ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
20
24
  const base: Omit<ComposeSpec, "images"> = {
21
25
  domain: "example.com",
22
26
  postgresPassword: "pgpw",
23
- rolePasswords: { admin: "adminpw", public: "publicpw" },
27
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
24
28
  caeloSecretKek: "0".repeat(64),
29
+ dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
25
30
  internalSecret: "i".repeat(48),
26
31
  anthropicApiKey: "sk-ant-x",
27
32
  diskSize: "20Gi",
@@ -53,6 +58,7 @@ describe("self-hosted Compose — internal secret (#770)", () => {
53
58
  expect(() =>
54
59
  generateDockerCompose({
55
60
  ...base,
61
+ dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
56
62
  internalSecret: "short",
57
63
  images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
58
64
  }),
@@ -68,10 +74,23 @@ describe("self-hosted Compose — connection budget (#1254)", () => {
68
74
 
69
75
  it("starts Postgres with the max_connections the budget is computed from", () => {
70
76
  expect(service(yaml, "postgres")).toContain(
71
- 'command: ["postgres", "-c", "max_connections=100"]',
77
+ 'command: ["postgres", "-c", "max_connections=100", ',
72
78
  );
73
79
  });
74
80
 
81
+ it("serves TLS with the install's own certificate, never a key it does not own (#1338)", () => {
82
+ const pg = service(yaml, "postgres");
83
+ expect(pg).toContain('"-c", "ssl=on"');
84
+ expect(pg).toContain("- ./postgres-tls/server.crt:/caelo-tls/server.crt:ro");
85
+ expect(pg).toContain("- ./postgres-tls/server.key:/caelo-tls/server.key:ro");
86
+ // The bind mount keeps the host's owner; Postgres only reads its own key.
87
+ expect(pg).toContain("install -o postgres -g postgres -m 0600 /caelo-tls/server.key");
88
+ expect(pg).toContain('exec docker-entrypoint.sh \\"$$@\\"');
89
+ // Only Postgres sees the server key; the CA key never leaves .caelo/.
90
+ expect(yaml.replace(pg, "")).not.toContain("server.key");
91
+ expect(yaml).not.toContain("ca.key");
92
+ });
93
+
75
94
  it("sizes the admin's two pools and the gateway's one", () => {
76
95
  expect(service(yaml, "caelo-admin")).toContain(
77
96
  ` CAELO_DB_POOL_MAX: "${COMPOSE_POOL_BUDGET.budget.adminPoolMax}"`,
@@ -82,6 +101,26 @@ describe("self-hosted Compose — connection budget (#1254)", () => {
82
101
  });
83
102
  });
84
103
 
104
+ describe("self-hosted Compose — database certificate renewal (#1338)", () => {
105
+ const render = (digest: string) =>
106
+ generateDockerCompose({
107
+ ...base,
108
+ dbTlsServerCertSha256: digest,
109
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
110
+ });
111
+
112
+ it("carries the server certificate's digest on Postgres, so a renewal recreates it", () => {
113
+ const before = service(render("a".repeat(64)), "postgres");
114
+ const after = service(render("c".repeat(64)), "postgres");
115
+ expect(before).toContain(`CAELO_DB_TLS_SERVER_CERT_SHA256: "${"a".repeat(64)}"`);
116
+ expect(after).not.toBe(before);
117
+ });
118
+
119
+ it("refuses a value that is no SHA-256 digest", () => {
120
+ expect(() => render("not-a-digest")).toThrow("dbTlsServerCertSha256");
121
+ });
122
+ });
123
+
85
124
  describe("self-hosted Compose — release images (#696)", () => {
86
125
  const yaml = generateDockerCompose({
87
126
  ...base,
@@ -104,10 +143,13 @@ describe("self-hosted Compose — release images (#696)", () => {
104
143
  it("mounts the bootstrap-token, site output + media dirs, never .caelo/ with its secrets", () => {
105
144
  const admin = service(yaml, "caelo-admin");
106
145
  expect(admin).toContain(
107
- " volumes:\n - ./bootstrap:/app/.caelo\n - ./output:/srv/caelo\n - ./media:/data/media",
146
+ " volumes:\n - ./bootstrap:/app/.caelo\n - ./output:/srv/caelo\n - ./media:/data/media\n - ./postgres-tls/ca.crt:/etc/caelo/db-ca/self-hosted-ca.pem:ro",
108
147
  );
109
148
  expect(yaml).not.toMatch(/- \.\/?:/);
110
- expect(service(yaml, "caelo-gateway")).not.toContain("volumes:");
149
+ // The gateway mounts the database CA (#1338) and nothing else.
150
+ expect(service(yaml, "caelo-gateway")).toContain(
151
+ " volumes:\n - ./postgres-tls/ca.crt:/etc/caelo/db-ca/self-hosted-ca.pem:ro\n restart:",
152
+ );
111
153
  expect(admin).toContain('CAELO_OUTPUT_ROOT: "/srv/caelo"');
112
154
  expect(admin).toContain('MEDIA_ROOT_DIR: "/data/media"');
113
155
  expect(service(yaml, "caddy")).toContain("- ./output:/srv/caelo:ro");
@@ -116,10 +158,10 @@ describe("self-hosted Compose — release images (#696)", () => {
116
158
  it("keeps the env + secret wiring of each service", () => {
117
159
  const admin = service(yaml, "caelo-admin");
118
160
  expect(admin).toContain(
119
- 'ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_admin"',
161
+ 'ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/self-hosted-ca.pem"',
120
162
  );
121
163
  expect(admin).toContain(
122
- 'PUBLIC_ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_public"',
164
+ 'PUBLIC_ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/self-hosted-ca.pem"',
123
165
  );
124
166
  expect(admin).toContain(`CAELO_SECRET_KEK: "${"0".repeat(64)}"`);
125
167
  expect(admin).toContain('CAELO_SITE_URL: "https://example.com"');
@@ -307,3 +349,135 @@ describe("self-hosted Compose — the host update agent (#696 Phase 4c)", () =>
307
349
  ).toThrow(/between 1024 and 65535/);
308
350
  });
309
351
  });
352
+
353
+ describe("self-hosted Compose — the MCP-Auth-Proxy (#1328)", () => {
354
+ const PROXY = `ghcr.io/caelo-cms/mcp-auth-proxy@sha256:${"c".repeat(64)}`;
355
+ const yaml = generateDockerCompose({
356
+ ...base,
357
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY, "mcp-auth-proxy": PROXY },
358
+ });
359
+ const proxy = service(yaml, COMPOSE_MCP_AUTH_PROXY_SERVICE);
360
+
361
+ it("runs the digest-pinned release image on the port Caddy proxies to", () => {
362
+ expect(proxy).toContain(`image: ${PROXY}`);
363
+ expect(proxy).toContain('MCP_AUTH_PROXY_PORT: "8095"');
364
+ expect(COMPOSE_MCP_AUTH_PROXY_UPSTREAM).toBe("caelo-mcp-auth-proxy:8095");
365
+ });
366
+
367
+ it("says remote MCP is unavailable here and trusts Caddy as the one hop", () => {
368
+ expect(proxy).toContain('CAELO_REMOTE_MCP: "unavailable"');
369
+ expect(proxy).toContain('CAELO_TRUSTED_PROXY_HOPS: "1"');
370
+ expect(proxy).toContain('CAELO_PROVIDER: "self-hosted"');
371
+ });
372
+
373
+ it("gets no database URL, no secret, no mount and no dependency on Postgres", () => {
374
+ expect(proxy).not.toMatch(/DATABASE|PASSWORD|SECRET|KEK|postgres:\/\/|volumes:|depends_on/);
375
+ expect(proxy).not.toContain("adminpw");
376
+ expect(proxy).not.toContain("publicpw");
377
+ });
378
+
379
+ it("runs locked down: read-only root, no capabilities, no privilege escalation", () => {
380
+ expect(proxy).toContain("read_only: true");
381
+ expect(proxy).toContain('cap_drop: ["ALL"]');
382
+ expect(proxy).toContain('security_opt: ["no-new-privileges:true"]');
383
+ });
384
+
385
+ it("is absent for a release recorded before the proxy shipped", () => {
386
+ const old = generateDockerCompose({
387
+ ...base,
388
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
389
+ });
390
+ expect(old).not.toContain(COMPOSE_MCP_AUTH_PROXY_SERVICE);
391
+ expect(old).toContain("\n\nvolumes:\n");
392
+ });
393
+
394
+ it("runs the checkout's proxy in dev mode", () => {
395
+ const dev = generateDockerCompose({ ...base, images: { mode: "dev" } });
396
+ expect(service(dev, COMPOSE_MCP_AUTH_PROXY_SERVICE)).toContain(
397
+ "command: bun run apps/mcp-auth-proxy/src/server.ts",
398
+ );
399
+ });
400
+
401
+ it("refuses a proxy image that is not a digest-pinned GHCR reference", () => {
402
+ expect(() =>
403
+ generateDockerCompose({
404
+ ...base,
405
+ images: {
406
+ mode: "release",
407
+ admin: ADMIN,
408
+ gateway: GATEWAY,
409
+ "mcp-auth-proxy": "ghcr.io/caelo-cms/mcp-auth-proxy:latest",
410
+ },
411
+ }),
412
+ ).toThrow(/mcp-auth-proxy image/);
413
+ });
414
+ });
415
+
416
+ describe("self-hosted Compose — the scheduler tick (#1140)", () => {
417
+ const yaml = generateDockerCompose({
418
+ ...base,
419
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
420
+ });
421
+ const scheduler = service(yaml, COMPOSE_SCHEDULER_SERVICE);
422
+
423
+ it("runs the admin image's clock against the admin's tick endpoint", () => {
424
+ expect(scheduler).toContain(`image: ${ADMIN}`);
425
+ expect(scheduler).toContain(
426
+ 'command: ["bun", "--bun", "/app/packages/admin-core/src/scheduler/clock.ts"]',
427
+ );
428
+ expect(COMPOSE_SCHEDULER_TICK_URL).toBe("http://caelo-admin:5173/api/internal/scheduler/tick");
429
+ expect(scheduler).toContain(`CAELO_SCHEDULER_TICK_URL: "${COMPOSE_SCHEDULER_TICK_URL}"`);
430
+ expect(scheduler).toContain("depends_on: [caelo-admin]");
431
+ });
432
+
433
+ it("ticks every 5 minutes by default and follows the knob", () => {
434
+ expect(scheduler).toContain('CAELO_SCHEDULER_INTERVAL_MINUTES: "5"');
435
+ const fast = generateDockerCompose({
436
+ ...base,
437
+ schedulerIntervalMinutes: 1,
438
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
439
+ });
440
+ expect(service(fast, COMPOSE_SCHEDULER_SERVICE)).toContain(
441
+ 'CAELO_SCHEDULER_INTERVAL_MINUTES: "1"',
442
+ );
443
+ });
444
+
445
+ it("refuses an interval outside 1-15 minutes", () => {
446
+ for (const bad of [0, 16, 2.5]) {
447
+ expect(() =>
448
+ generateDockerCompose({
449
+ ...base,
450
+ schedulerIntervalMinutes: bad,
451
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
452
+ }),
453
+ ).toThrow("schedulerIntervalMinutes");
454
+ }
455
+ });
456
+
457
+ it("holds only the internal secret it signs with: no database, no KEK, no mount", () => {
458
+ expect(scheduler).toContain(`CAELO_INTERNAL_SECRET: "${"i".repeat(48)}"`);
459
+ expect(scheduler).not.toMatch(/DATABASE|PASSWORD|KEK|postgres:\/\/|volumes:/);
460
+ expect(scheduler).not.toContain("adminpw");
461
+ expect(scheduler).not.toContain("0".repeat(64));
462
+ });
463
+
464
+ it("runs locked down: read-only root, no capabilities, no privilege escalation", () => {
465
+ expect(scheduler).toContain("read_only: true");
466
+ expect(scheduler).toContain('cap_drop: ["ALL"]');
467
+ expect(scheduler).toContain('security_opt: ["no-new-privileges:true"]');
468
+ });
469
+
470
+ it("runs the checkout's clock in dev mode", () => {
471
+ const dev = generateDockerCompose({ ...base, images: { mode: "dev" } });
472
+ expect(service(dev, COMPOSE_SCHEDULER_SERVICE)).toContain(
473
+ "command: bun run packages/admin-core/src/scheduler/clock.ts",
474
+ );
475
+ });
476
+
477
+ it("is part of every rendered Compose file, so an upgrade of an older install adds it", () => {
478
+ // self-hosted-upgrade.ts rolls onto renderCompose(<new images>), which is
479
+ // this generator: the next upgrade of an install without the service
480
+ // writes a Compose file that has it, and `docker compose up -d` starts it.
481
+ expect(yaml).toContain(`\n ${COMPOSE_SCHEDULER_SERVICE}:\n`);
482
+ });
483
+ });
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { afterEach, describe, expect, it } from "bun:test";
4
+ import { X509Certificate } from "node:crypto";
5
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import {
9
+ ensureSelfHostedDbTls,
10
+ generateDatabaseTls,
11
+ SELF_HOSTED_DB_HOST,
12
+ SELF_HOSTED_TLS_DIR,
13
+ serverCertProblem,
14
+ } from "./self-hosted-db-tls.js";
15
+
16
+ const dirs: string[] = [];
17
+ afterEach(() => {
18
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
19
+ });
20
+ function tempDir(): string {
21
+ const d = mkdtempSync(join(tmpdir(), "caelo-db-tls-"));
22
+ dirs.push(d);
23
+ return d;
24
+ }
25
+
26
+ describe("generateDatabaseTls", () => {
27
+ it("makes a CA and a server certificate for `postgres` that chains to it", () => {
28
+ const files = generateDatabaseTls();
29
+ const ca = new X509Certificate(files.caCert);
30
+ const server = new X509Certificate(files.serverCert);
31
+ expect(ca.ca).toBe(true);
32
+ expect(server.ca).toBe(false);
33
+ expect(ca.verify(ca.publicKey)).toBe(true);
34
+ expect(server.verify(ca.publicKey)).toBe(true);
35
+ expect(server.checkHost(SELF_HOSTED_DB_HOST)).toBe(SELF_HOSTED_DB_HOST);
36
+ expect(server.checkHost("evil.example")).toBeUndefined();
37
+ expect(server.issuer).toBe(ca.subject);
38
+ expect(serverCertProblem(files.caCert, files.serverCert, new Date())).toBeNull();
39
+ });
40
+
41
+ it("passes a real TLS handshake with host-name verification (Bun's TLS)", async () => {
42
+ // The same check a verify-full Postgres client makes: the chain must end
43
+ // at the CA we pass, and the certificate must name the host dialled.
44
+ // `localhost` stands in for `postgres` (resolvable here), via the same
45
+ // generator path.
46
+ const files = generateDatabaseTls();
47
+ using server = Bun.serve({
48
+ port: 0,
49
+ hostname: "127.0.0.1",
50
+ tls: { cert: files.serverCert, key: files.serverKey },
51
+ fetch: () => new Response("ok"),
52
+ });
53
+ const url = `https://127.0.0.1:${server.port}/`;
54
+ // Right CA + the certificate's host name (SNI/verify against `postgres`).
55
+ const ok = await fetch(url, {
56
+ tls: {
57
+ ca: files.caCert,
58
+ checkServerIdentity: (_host, cert) =>
59
+ cert.subjectaltname === `DNS:${SELF_HOSTED_DB_HOST}`
60
+ ? undefined
61
+ : new Error(`unexpected SAN ${cert.subjectaltname}`),
62
+ },
63
+ });
64
+ expect(await ok.text()).toBe("ok");
65
+ // Another CA: the chain does not verify.
66
+ await expect(fetch(url, { tls: { ca: generateDatabaseTls().caCert } })).rejects.toThrow();
67
+ });
68
+ });
69
+
70
+ describe("ensureSelfHostedDbTls", () => {
71
+ it("creates the files once, keys 0600, and keeps them on a re-run", () => {
72
+ const caelo = tempDir();
73
+ expect(ensureSelfHostedDbTls(caelo)).toHaveLength(1);
74
+ const dir = join(caelo, SELF_HOSTED_TLS_DIR);
75
+ expect(statSync(join(dir, "ca.key")).mode & 0o777).toBe(0o600);
76
+ expect(statSync(join(dir, "server.key")).mode & 0o777).toBe(0o600);
77
+ const ca = readFileSync(join(dir, "ca.crt"), "utf8");
78
+ const server = readFileSync(join(dir, "server.crt"), "utf8");
79
+ expect(ensureSelfHostedDbTls(caelo)).toEqual([]);
80
+ expect(readFileSync(join(dir, "ca.crt"), "utf8")).toBe(ca);
81
+ expect(readFileSync(join(dir, "server.crt"), "utf8")).toBe(server);
82
+ });
83
+
84
+ it("re-issues a server certificate near its end from the same CA", () => {
85
+ const caelo = tempDir();
86
+ ensureSelfHostedDbTls(caelo);
87
+ const dir = join(caelo, SELF_HOSTED_TLS_DIR);
88
+ const ca = readFileSync(join(dir, "ca.crt"), "utf8");
89
+ const later = new Date(Date.now() + 9.5 * 365 * 24 * 3600 * 1000);
90
+ const done = ensureSelfHostedDbTls(caelo, later);
91
+ expect(done[0]).toMatch(/re-issued.*expires/);
92
+ expect(readFileSync(join(dir, "ca.crt"), "utf8")).toBe(ca);
93
+ const fresh = readFileSync(join(dir, "server.crt"), "utf8");
94
+ expect(serverCertProblem(ca, fresh, later)).toBeNull();
95
+ });
96
+
97
+ it("re-issues a server certificate another CA signed", () => {
98
+ const caelo = tempDir();
99
+ ensureSelfHostedDbTls(caelo);
100
+ const dir = join(caelo, SELF_HOSTED_TLS_DIR);
101
+ writeFileSync(join(dir, "server.crt"), generateDatabaseTls().serverCert);
102
+ expect(ensureSelfHostedDbTls(caelo)[0]).toMatch(/not signed by the install's CA/);
103
+ });
104
+
105
+ it("refuses half a CA instead of silently replacing the trust anchor", () => {
106
+ const caelo = tempDir();
107
+ ensureSelfHostedDbTls(caelo);
108
+ rmSync(join(caelo, SELF_HOSTED_TLS_DIR, "ca.key"));
109
+ expect(() => ensureSelfHostedDbTls(caelo)).toThrow(/only half of the database CA/);
110
+ });
111
+ });
@@ -0,0 +1,287 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1338 (gateway hardening L6) — verify-full TLS between the self-hosted
5
+ * Compose services and their Postgres.
6
+ *
7
+ * The admin and the gateway reach Postgres over the Compose network as
8
+ * `postgres:5432`. With `sslmode=verify-full` they need a server
9
+ * certificate for exactly that host name, signed by a CA they trust. No
10
+ * public CA issues a certificate for `postgres`, so every install makes its
11
+ * own: an ECDSA P-256 CA, generated once and kept in `.caelo/postgres-tls/`
12
+ * (like the install's other secrets in `.caelo/`), and a server certificate
13
+ * it signs. The CA certificate is mounted read-only into the admin and the
14
+ * gateway at shared `SELF_HOSTED_CA_FILE`; the server key only into
15
+ * Postgres.
16
+ *
17
+ * Generated with `node:crypto` and a minimal DER encoder below, so an
18
+ * install needs neither openssl on the host nor a new dependency (§3).
19
+ * Postgres reads ECDSA keys; Bun's TLS verifies the chain and the host.
20
+ *
21
+ * Re-runs keep what exists (the CA never changes once made, so no client
22
+ * ever needs a new trust anchor); a server certificate that is missing,
23
+ * does not chain to the CA, does not name `postgres`, or expires within a
24
+ * year is re-issued from the stored CA key.
25
+ */
26
+
27
+ import {
28
+ createHash,
29
+ createPrivateKey,
30
+ generateKeyPairSync,
31
+ type KeyObject,
32
+ randomBytes,
33
+ sign,
34
+ X509Certificate,
35
+ } from "node:crypto";
36
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
37
+ import { join } from "node:path";
38
+
39
+ /** The directory under `.caelo/` that holds the install's database TLS files. */
40
+ export const SELF_HOSTED_TLS_DIR = "postgres-tls";
41
+
42
+ /** The host name the services dial Postgres by on the Compose network. */
43
+ export const SELF_HOSTED_DB_HOST = "postgres";
44
+
45
+ /** File names inside {@link SELF_HOSTED_TLS_DIR}. */
46
+ export const SELF_HOSTED_TLS_FILES = {
47
+ caCert: "ca.crt",
48
+ caKey: "ca.key",
49
+ serverCert: "server.crt",
50
+ serverKey: "server.key",
51
+ } as const;
52
+
53
+ const YEAR_MS = 365 * 24 * 3600 * 1000;
54
+ const CA_VALIDITY_YEARS = 30;
55
+ const SERVER_VALIDITY_YEARS = 10;
56
+ /** A server certificate closer than this to its end is re-issued. */
57
+ const RENEW_BEFORE_MS = YEAR_MS;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Minimal DER (X.690) — only what an X.509 v3 certificate needs.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function derLength(n: number): Buffer {
64
+ if (n < 0x80) return Buffer.from([n]);
65
+ const bytes: number[] = [];
66
+ for (let v = n; v > 0; v = Math.floor(v / 256)) bytes.unshift(v & 0xff);
67
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
68
+ }
69
+
70
+ function tlv(tag: number, value: Buffer): Buffer {
71
+ return Buffer.concat([Buffer.from([tag]), derLength(value.length), value]);
72
+ }
73
+
74
+ const seq = (...items: Buffer[]) => tlv(0x30, Buffer.concat(items));
75
+ const set = (...items: Buffer[]) => tlv(0x31, Buffer.concat(items));
76
+ const explicit = (n: number, inner: Buffer) => tlv(0xa0 + n, inner);
77
+ const octets = (b: Buffer) => tlv(0x04, b);
78
+ /** A BIT STRING; `unused` trailing bits of the last byte (DER: minimal for named bits). */
79
+ const bitString = (b: Buffer, unused = 0) => tlv(0x03, Buffer.concat([Buffer.from([unused]), b]));
80
+ const utf8 = (s: string) => tlv(0x0c, Buffer.from(s, "utf8"));
81
+ const bool = (v: boolean) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
82
+
83
+ function integer(b: Buffer): Buffer {
84
+ let v = b;
85
+ while (v.length > 1 && v[0] === 0 && (v[1] ?? 0) < 0x80) v = v.subarray(1);
86
+ if ((v[0] ?? 0) >= 0x80) v = Buffer.concat([Buffer.from([0]), v]);
87
+ return tlv(0x02, v);
88
+ }
89
+
90
+ function oid(dotted: string): Buffer {
91
+ const parts = dotted.split(".").map(Number);
92
+ const out: number[] = [40 * (parts[0] ?? 0) + (parts[1] ?? 0)];
93
+ for (const p of parts.slice(2)) {
94
+ const chunk: number[] = [p & 0x7f];
95
+ for (let v = Math.floor(p / 128); v > 0; v = Math.floor(v / 128))
96
+ chunk.unshift(0x80 | (v & 0x7f));
97
+ out.push(...chunk);
98
+ }
99
+ return tlv(0x06, Buffer.from(out));
100
+ }
101
+
102
+ /** UTCTime until 2049, GeneralizedTime from 2050 (RFC 5280 §4.1.2.5). */
103
+ function time(d: Date): Buffer {
104
+ const iso = d.toISOString().replace(/[-:T]/g, "").slice(0, 14);
105
+ return d.getUTCFullYear() < 2050
106
+ ? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`))
107
+ : tlv(0x18, Buffer.from(`${iso}Z`));
108
+ }
109
+
110
+ const ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
111
+ const name = (cn: string) => seq(set(seq(oid("2.5.4.3"), utf8(cn))));
112
+ const extension = (id: string, critical: boolean, value: Buffer) =>
113
+ seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
114
+
115
+ interface CertSpec {
116
+ readonly subject: string;
117
+ readonly issuer: string;
118
+ readonly publicKey: KeyObject;
119
+ readonly signingKey: KeyObject;
120
+ readonly notBefore: Date;
121
+ readonly notAfter: Date;
122
+ readonly ca: boolean;
123
+ /** DNS subjectAltNames (server certificates). */
124
+ readonly dnsNames?: readonly string[];
125
+ }
126
+
127
+ function certificatePem(spec: CertSpec): string {
128
+ const spki = spec.publicKey.export({ type: "spki", format: "der" });
129
+ const exts = [
130
+ extension("2.5.29.19", true, spec.ca ? seq(bool(true)) : seq()),
131
+ // keyCertSign + cRLSign for the CA; digitalSignature for the server.
132
+ extension(
133
+ "2.5.29.15",
134
+ true,
135
+ spec.ca ? bitString(Buffer.from([0x06]), 1) : bitString(Buffer.from([0x80]), 7),
136
+ ),
137
+ ...(spec.ca
138
+ ? []
139
+ : [
140
+ extension("2.5.29.37", false, seq(oid("1.3.6.1.5.5.7.3.1"))),
141
+ extension(
142
+ "2.5.29.17",
143
+ false,
144
+ seq(...(spec.dnsNames ?? []).map((n) => tlv(0x82, Buffer.from(n, "ascii")))),
145
+ ),
146
+ ]),
147
+ ];
148
+ const tbs = seq(
149
+ explicit(0, integer(Buffer.from([2]))),
150
+ integer(Buffer.concat([Buffer.from([0x01]), randomBytes(15)])),
151
+ ECDSA_SHA256,
152
+ name(spec.issuer),
153
+ seq(time(spec.notBefore), time(spec.notAfter)),
154
+ name(spec.subject),
155
+ spki,
156
+ explicit(3, seq(...exts)),
157
+ );
158
+ const signature = sign("sha256", tbs, { key: spec.signingKey, dsaEncoding: "der" });
159
+ const der = seq(tbs, ECDSA_SHA256, bitString(signature));
160
+ const b64 = der.toString("base64").replace(/.{64}/g, "$&\n").replace(/\n$/, "");
161
+ return `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n`;
162
+ }
163
+
164
+ /** The PEM files of an install's database TLS. */
165
+ export interface DatabaseTlsFiles {
166
+ readonly caCert: string;
167
+ readonly caKey: string;
168
+ readonly serverCert: string;
169
+ readonly serverKey: string;
170
+ }
171
+
172
+ function newKeyPair() {
173
+ return generateKeyPairSync("ec", { namedCurve: "P-256" });
174
+ }
175
+
176
+ /** A server certificate for {@link SELF_HOSTED_DB_HOST}, signed by the CA. */
177
+ function issueServer(
178
+ ca: { cert: string; key: KeyObject },
179
+ now: Date,
180
+ ): Pick<DatabaseTlsFiles, "serverCert" | "serverKey"> {
181
+ const server = newKeyPair();
182
+ const caSubject = new X509Certificate(ca.cert).subject.replace(/^CN=/, "");
183
+ return {
184
+ serverCert: certificatePem({
185
+ subject: SELF_HOSTED_DB_HOST,
186
+ issuer: caSubject,
187
+ publicKey: server.publicKey,
188
+ signingKey: ca.key,
189
+ notBefore: new Date(now.getTime() - 3600 * 1000),
190
+ notAfter: new Date(now.getTime() + SERVER_VALIDITY_YEARS * YEAR_MS),
191
+ ca: false,
192
+ dnsNames: [SELF_HOSTED_DB_HOST],
193
+ }),
194
+ serverKey: server.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
195
+ };
196
+ }
197
+
198
+ /** A fresh CA and a server certificate it signed. */
199
+ export function generateDatabaseTls(now: Date = new Date()): DatabaseTlsFiles {
200
+ const ca = newKeyPair();
201
+ const caCert = certificatePem({
202
+ subject: "Caelo self-hosted database CA",
203
+ issuer: "Caelo self-hosted database CA",
204
+ publicKey: ca.publicKey,
205
+ signingKey: ca.privateKey,
206
+ notBefore: new Date(now.getTime() - 3600 * 1000),
207
+ notAfter: new Date(now.getTime() + CA_VALIDITY_YEARS * YEAR_MS),
208
+ ca: true,
209
+ });
210
+ return {
211
+ caCert,
212
+ caKey: ca.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
213
+ ...issueServer({ cert: caCert, key: ca.privateKey }, now),
214
+ };
215
+ }
216
+
217
+ /** Why `serverCert` must be re-issued, or null when it is fine. */
218
+ export function serverCertProblem(caCert: string, serverCert: string, now: Date): string | null {
219
+ try {
220
+ const ca = new X509Certificate(caCert);
221
+ const server = new X509Certificate(serverCert);
222
+ if (!server.verify(ca.publicKey)) return "it is not signed by the install's CA";
223
+ if (server.checkHost(SELF_HOSTED_DB_HOST) !== SELF_HOSTED_DB_HOST) {
224
+ return `it does not name ${SELF_HOSTED_DB_HOST}`;
225
+ }
226
+ if (Date.parse(server.validTo) - now.getTime() < RENEW_BEFORE_MS) {
227
+ return `it expires ${server.validTo}`;
228
+ }
229
+ return null;
230
+ } catch (e) {
231
+ return `it does not parse (${e instanceof Error ? e.message : String(e)})`;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Make sure `<caeloDir>/postgres-tls/` holds the install's CA and a valid
237
+ * server certificate; returns what it did (empty when nothing changed).
238
+ * The CA is created once and never replaced.
239
+ */
240
+ export function ensureSelfHostedDbTls(caeloDir: string, now: Date = new Date()): string[] {
241
+ const dir = join(caeloDir, SELF_HOSTED_TLS_DIR);
242
+ const path = (f: keyof typeof SELF_HOSTED_TLS_FILES) => join(dir, SELF_HOSTED_TLS_FILES[f]);
243
+ // The CA key and the server key are secrets; the certificates are not,
244
+ // but every container reads them through a bind mount, so 0644.
245
+ const write = (f: keyof typeof SELF_HOSTED_TLS_FILES, content: string) =>
246
+ writeFileSync(path(f), content, { mode: f.endsWith("Key") ? 0o600 : 0o644 });
247
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
248
+ const done: string[] = [];
249
+ if (!existsSync(path("caCert")) || !existsSync(path("caKey"))) {
250
+ if (existsSync(path("caCert")) !== existsSync(path("caKey"))) {
251
+ throw new Error(
252
+ `${dir} holds only half of the database CA (${SELF_HOSTED_TLS_FILES.caCert} / ${SELF_HOSTED_TLS_FILES.caKey}). Restore the missing file from your backup of .caelo/, or delete both to make a new CA (every service is re-created with it on the next \`cms-provision up\`).`,
253
+ );
254
+ }
255
+ const files = generateDatabaseTls(now);
256
+ for (const f of Object.keys(SELF_HOSTED_TLS_FILES) as (keyof typeof SELF_HOSTED_TLS_FILES)[]) {
257
+ write(f, files[f]);
258
+ }
259
+ done.push(
260
+ `created the install's database CA and the server certificate for ${SELF_HOSTED_DB_HOST}`,
261
+ );
262
+ return done;
263
+ }
264
+ const caCert = readFileSync(path("caCert"), "utf8");
265
+ const problem = existsSync(path("serverCert"))
266
+ ? serverCertProblem(caCert, readFileSync(path("serverCert"), "utf8"), now)
267
+ : "it is missing";
268
+ if (problem === null && existsSync(path("serverKey"))) return done;
269
+ const key = createPrivateKey(readFileSync(path("caKey"), "utf8"));
270
+ const issued = issueServer({ cert: caCert, key }, now);
271
+ write("serverCert", issued.serverCert);
272
+ write("serverKey", issued.serverKey);
273
+ done.push(`re-issued the database server certificate (${problem ?? "its key was missing"})`);
274
+ return done;
275
+ }
276
+
277
+ /**
278
+ * SHA-256 (hex) of the install's database server certificate, which the
279
+ * Compose file carries on the Postgres service so a renewed certificate
280
+ * recreates it (compose.ts `dbTlsServerCertSha256`).
281
+ *
282
+ * @throws when the certificate is missing: call {@link ensureSelfHostedDbTls} first.
283
+ */
284
+ export function selfHostedDbTlsServerCertSha256(caeloDir: string): string {
285
+ const cert = readFileSync(join(caeloDir, SELF_HOSTED_TLS_DIR, SELF_HOSTED_TLS_FILES.serverCert));
286
+ return createHash("sha256").update(cert).digest("hex");
287
+ }