@caelo-cms/provisioning 0.11.8 → 0.11.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-domain.d.ts +10 -1
- package/dist/admin-domain.d.ts.map +1 -1
- package/dist/admin-domain.js +13 -3
- package/dist/admin-domain.js.map +1 -1
- package/dist/adopt-existing.d.ts +6 -1
- package/dist/adopt-existing.d.ts.map +1 -1
- package/dist/adopt-existing.js +86 -0
- package/dist/adopt-existing.js.map +1 -1
- package/dist/agent/command.d.ts +20 -0
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +32 -9
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/run.d.ts +2 -0
- package/dist/agent/run.d.ts.map +1 -1
- package/dist/agent/run.js +5 -1
- package/dist/agent/run.js.map +1 -1
- package/dist/agent-db.js +65 -3
- package/dist/aws-task-roles.d.ts +4 -2
- package/dist/aws-task-roles.d.ts.map +1 -1
- package/dist/aws-task-roles.js +4 -1
- package/dist/aws-task-roles.js.map +1 -1
- package/dist/caddy.d.ts +21 -4
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +27 -3
- package/dist/caddy.js.map +1 -1
- package/dist/cli.js +12 -3
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-tls.d.ts +63 -0
- package/dist/cloudsql-tls.d.ts.map +1 -0
- package/dist/cloudsql-tls.js +289 -0
- package/dist/cloudsql-tls.js.map +1 -0
- package/dist/compose.d.ts +29 -6
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +95 -23
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +1 -0
- package/dist/converge-checkpoint.d.ts.map +1 -1
- package/dist/converge-checkpoint.js +8 -1
- package/dist/converge-checkpoint.js.map +1 -1
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +2 -11
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/deployed-release.d.ts +4 -1
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +18 -8
- package/dist/deployed-release.js.map +1 -1
- package/dist/gcp-names.d.ts +21 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +24 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/gcp-url-map.d.ts.map +1 -1
- package/dist/gcp-url-map.js +6 -2
- package/dist/gcp-url-map.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +24 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-state.d.ts +6 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +17 -4
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +17 -8
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-auth-proxy-setup.d.ts +48 -0
- package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
- package/dist/mcp-auth-proxy-setup.js +280 -0
- package/dist/mcp-auth-proxy-setup.js.map +1 -0
- package/dist/release-manifest.d.ts +25 -1
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +36 -3
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp.d.ts +55 -18
- package/dist/remote-mcp.d.ts.map +1 -1
- package/dist/remote-mcp.js +307 -77
- package/dist/remote-mcp.js.map +1 -1
- package/dist/runtime-secrets.d.ts +9 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +21 -10
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/seal-key-rotation.d.ts +7 -7
- package/dist/seal-key-rotation.d.ts.map +1 -1
- package/dist/seal-key-rotation.js +22 -22
- package/dist/seal-key-rotation.js.map +1 -1
- package/dist/self-hosted-db-tls.d.ts +37 -0
- package/dist/self-hosted-db-tls.d.ts.map +1 -0
- package/dist/self-hosted-db-tls.js +220 -0
- package/dist/self-hosted-db-tls.js.map +1 -0
- package/dist/self-hosted-release.d.ts +3 -1
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +33 -9
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +34 -0
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +52 -0
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-start.d.ts +0 -21
- package/dist/self-hosted-start.d.ts.map +1 -1
- package/dist/self-hosted-start.js +39 -1
- package/dist/self-hosted-start.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +15 -6
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +72 -23
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +170 -25
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +314 -37
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +31 -0
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +71 -30
- package/dist/stack-converge.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +25 -4
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +63 -17
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +99 -12
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/health.d.ts +31 -7
- package/dist/upgrade/health.d.ts.map +1 -1
- package/dist/upgrade/health.js +41 -17
- package/dist/upgrade/health.js.map +1 -1
- package/dist/upgrade/image-forms.d.ts +1 -1
- package/dist/upgrade/image-forms.d.ts.map +1 -1
- package/dist/upgrade/image-forms.js +24 -7
- package/dist/upgrade/image-forms.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +12 -3
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +13 -4
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +13 -3
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +34 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +12 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +49 -1
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +6 -1
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/upgrader-job.d.ts +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +20 -14
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +1 -1
- package/dist/upgrader-records.d.ts.map +1 -1
- package/dist/upgrader-records.js +26 -7
- package/dist/upgrader-records.js.map +1 -1
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +10 -5
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +22 -7
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +43 -17
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-domain.test.ts +9 -8
- package/src/admin-domain.ts +13 -3
- package/src/admin-memory.test.ts +1 -0
- package/src/adopt-existing.test.ts +39 -0
- package/src/adopt-existing.ts +83 -1
- package/src/agent/command.test.ts +49 -1
- package/src/agent/command.ts +36 -9
- package/src/agent/run.test.ts +19 -0
- package/src/agent/run.ts +11 -2
- package/src/aws-task-roles.ts +5 -2
- package/src/caddy-redirects.docker.test.ts +2 -0
- package/src/caddy-redirects.test.ts +3 -0
- package/src/caddy-validate.test.ts +7 -1
- package/src/caddy.test.ts +39 -1
- package/src/caddy.ts +36 -4
- package/src/checkpoint-secrets.test.ts +14 -6
- package/src/cli.ts +12 -1
- package/src/cloudsql-tls.test.ts +225 -0
- package/src/cloudsql-tls.ts +352 -0
- package/src/compose.ts +125 -24
- package/src/converge-checkpoint.ts +8 -1
- package/src/converge-job-gcp.ts +2 -11
- package/src/database-tls-builders.test.ts +55 -0
- package/src/deployed-release.test.ts +104 -40
- package/src/deployed-release.ts +30 -9
- package/src/gateway-env-isolation.test.ts +10 -5
- package/src/gcp-names.ts +31 -0
- package/src/gcp-url-map.test.ts +6 -8
- package/src/gcp-url-map.ts +6 -2
- package/src/index.test.ts +7 -1
- package/src/index.ts +1 -0
- package/src/infra-contract.test.ts +5 -3
- package/src/infra-contract.ts +24 -1
- package/src/install-state.ts +26 -4
- package/src/lifecycle.ts +20 -11
- package/src/mcp-auth-proxy-setup.test.ts +305 -0
- package/src/mcp-auth-proxy-setup.ts +374 -0
- package/src/mcp-iap-stacks.test.ts +1 -1
- package/src/media-volume.test.ts +1 -1
- package/src/operator-access-stacks.test.ts +1 -1
- package/src/operator-access.test.ts +5 -4
- package/src/pool-budget-stacks.test.ts +1 -1
- package/src/release-manifest.test.ts +25 -0
- package/src/release-manifest.ts +59 -4
- package/src/remote-mcp-stack-config.test.ts +19 -9
- package/src/remote-mcp.test.ts +252 -57
- package/src/remote-mcp.ts +336 -77
- package/src/runtime-secrets.test.ts +20 -7
- package/src/runtime-secrets.ts +50 -9
- package/src/seal-key-rotation.test.ts +2 -2
- package/src/seal-key-rotation.ts +25 -25
- package/src/self-hosted-compose.test.ts +108 -5
- package/src/self-hosted-db-tls.test.ts +111 -0
- package/src/self-hosted-db-tls.ts +287 -0
- package/src/self-hosted-release-resolve.test.ts +71 -0
- package/src/self-hosted-release.ts +41 -11
- package/src/self-hosted-roles.test.ts +54 -0
- package/src/self-hosted-roles.ts +58 -0
- package/src/self-hosted-start.test.ts +34 -2
- package/src/self-hosted-start.ts +20 -2
- package/src/self-hosted-upgrade.test.ts +103 -14
- package/src/self-hosted-upgrade.ts +115 -29
- package/src/stack-contract-parity.test.ts +79 -12
- package/src/stack-contract.test.ts +169 -29
- package/src/stack-contract.ts +362 -42
- package/src/stack-converge-secrets.test.ts +26 -13
- package/src/stack-converge.test.ts +199 -26
- package/src/stack-converge.ts +92 -30
- package/src/state-store.test.ts +2 -1
- package/src/upgrade/app-roll.ts +97 -19
- package/src/upgrade/converge.ts +120 -11
- package/src/upgrade/health.test.ts +63 -25
- package/src/upgrade/health.ts +54 -22
- package/src/upgrade/image-forms.test.ts +34 -0
- package/src/upgrade/image-forms.ts +23 -8
- package/src/upgrade/release-images.test.ts +5 -1
- package/src/upgrade/release-images.ts +22 -6
- package/src/upgrade/upgrade.test.ts +353 -44
- package/src/upgrade/upgrade.ts +55 -6
- package/src/upgrader-gcp.ts +65 -3
- package/src/upgrader-iam-preflight.test.ts +19 -0
- package/src/upgrader-iam-preflight.ts +6 -0
- package/src/upgrader-job.test.ts +101 -27
- package/src/upgrader-job.ts +27 -14
- package/src/upgrader-records.test.ts +97 -12
- package/src/upgrader-records.ts +28 -11
- package/src/upgrader-setup.test.ts +2 -0
- package/src/upgrader-setup.ts +10 -4
- package/src/wizards/gcp-cost.test.ts +50 -0
- package/src/wizards/gcp-cost.ts +24 -7
- package/src/wizards/gcp.ts +55 -14
- package/stacks/aws/index.ts +32 -14
- package/stacks/azure/index.ts +57 -18
- package/stacks/gcp/index.ts +83 -23
- package/stacks/gcp-firebase/index.ts +162 -33
- package/stacks/self-hosted/README.md +2 -1
- package/stacks/self-hosted/index.ts +11 -1
package/src/runtime-secrets.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Pulumi) creates and maintains, through gcloud:
|
|
6
6
|
*
|
|
7
7
|
* - {@link ensureGatewayServiceAccount}: the gateway's own run SA, so it
|
|
8
|
-
* can be granted only the secrets the gateway reads
|
|
8
|
+
* can be granted only the secrets the gateway reads; the same for the
|
|
9
|
+
* MCP-Auth-Proxy ({@link ensureMcpAuthProxyServiceAccount}, #1328).
|
|
9
10
|
* - {@link ensureGeneratedSecrets}: the secrets in
|
|
10
11
|
* `CLI_GENERATED_SECRETS` (stack-contract.ts). The wizard runs it before
|
|
11
12
|
* `pulumi up`, upgrade on every roll, so installs that predate a secret
|
|
@@ -23,9 +24,16 @@
|
|
|
23
24
|
import { randomBytes } from "node:crypto";
|
|
24
25
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
25
26
|
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
gatewayServiceAccountEmail,
|
|
29
|
+
gatewayServiceAccountId,
|
|
30
|
+
gcpSecretId,
|
|
31
|
+
mcpAuthProxyServiceAccountEmail,
|
|
32
|
+
mcpAuthProxyServiceAccountId,
|
|
33
|
+
} from "./gcp-names.js";
|
|
27
34
|
import {
|
|
28
35
|
CLI_GENERATED_SECRETS,
|
|
36
|
+
CLOUD_RUN_SLUGS,
|
|
29
37
|
type CloudRunSlug,
|
|
30
38
|
DATABASE_ROLE_SECRET,
|
|
31
39
|
type DatabaseRole,
|
|
@@ -84,14 +92,47 @@ const ALREADY_EXISTS = /ALREADY_EXISTS|already exists/i;
|
|
|
84
92
|
// ===========================================================================
|
|
85
93
|
|
|
86
94
|
/** Create the gateway's run SA unless it exists. */
|
|
87
|
-
export
|
|
95
|
+
export function ensureGatewayServiceAccount(
|
|
96
|
+
install: { readonly projectId: string; readonly env: string },
|
|
97
|
+
deps: RuntimeDeps = {},
|
|
98
|
+
): Promise<EnsureOutcome> {
|
|
99
|
+
return ensureServiceAccount(
|
|
100
|
+
install.projectId,
|
|
101
|
+
gatewayServiceAccountId(install.env),
|
|
102
|
+
gatewayServiceAccountEmail(install.projectId, install.env),
|
|
103
|
+
`Caelo ${install.env} gateway`,
|
|
104
|
+
deps,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* #1328 — create the MCP-Auth-Proxy's run SA unless it exists. It gets no
|
|
110
|
+
* role here or anywhere: only `secretAccessor` on its two secrets
|
|
111
|
+
* (stack-contract.ts runtimeSecretBindings).
|
|
112
|
+
*/
|
|
113
|
+
export function ensureMcpAuthProxyServiceAccount(
|
|
88
114
|
install: { readonly projectId: string; readonly env: string },
|
|
89
115
|
deps: RuntimeDeps = {},
|
|
116
|
+
): Promise<EnsureOutcome> {
|
|
117
|
+
return ensureServiceAccount(
|
|
118
|
+
install.projectId,
|
|
119
|
+
mcpAuthProxyServiceAccountId(install.env),
|
|
120
|
+
mcpAuthProxyServiceAccountEmail(install.projectId, install.env),
|
|
121
|
+
`Caelo ${install.env} MCP-Auth-Proxy`,
|
|
122
|
+
deps,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function ensureServiceAccount(
|
|
127
|
+
projectId: string,
|
|
128
|
+
accountId: string,
|
|
129
|
+
email: string,
|
|
130
|
+
displayName: string,
|
|
131
|
+
deps: RuntimeDeps,
|
|
90
132
|
): Promise<EnsureOutcome> {
|
|
91
133
|
const run = deps.run ?? defaultGcloud;
|
|
92
|
-
const email = gatewayServiceAccountEmail(install.projectId, install.env);
|
|
93
134
|
const id = `service account ${email}`;
|
|
94
|
-
const project = `--project=${
|
|
135
|
+
const project = `--project=${projectId}`;
|
|
95
136
|
const describe = await run([
|
|
96
137
|
"iam",
|
|
97
138
|
"service-accounts",
|
|
@@ -108,9 +149,9 @@ export async function ensureGatewayServiceAccount(
|
|
|
108
149
|
"iam",
|
|
109
150
|
"service-accounts",
|
|
110
151
|
"create",
|
|
111
|
-
|
|
152
|
+
accountId,
|
|
112
153
|
project,
|
|
113
|
-
`--display-name
|
|
154
|
+
`--display-name=${displayName}`,
|
|
114
155
|
"--quiet",
|
|
115
156
|
]);
|
|
116
157
|
if (create.ok || ALREADY_EXISTS.test(create.stderr)) return { id, status: "applied" };
|
|
@@ -470,7 +511,7 @@ async function checkRotationReaders(
|
|
|
470
511
|
const isSecret = (live: { kind: string; secret?: string } | undefined) =>
|
|
471
512
|
live?.kind === "secret" &&
|
|
472
513
|
(live.secret === secretId || (live.secret?.endsWith(`/secrets/${secretId}`) ?? false));
|
|
473
|
-
for (const service of
|
|
514
|
+
for (const service of CLOUD_RUN_SLUGS) {
|
|
474
515
|
const vars = Object.entries(SERVICE_SECRET_ENV[service])
|
|
475
516
|
.filter(([, s]) => s === secret)
|
|
476
517
|
.map(([name]) => name);
|
|
@@ -696,7 +737,7 @@ export async function rotateRuntimeSecret(
|
|
|
696
737
|
steps.push(`added a new version to ${secretId}`);
|
|
697
738
|
|
|
698
739
|
const failures: string[] = [];
|
|
699
|
-
for (const service of
|
|
740
|
+
for (const service of CLOUD_RUN_SLUGS) {
|
|
700
741
|
if (!serviceSecrets(service).includes(secret)) continue;
|
|
701
742
|
const name = target.services[service];
|
|
702
743
|
const [update, flip] = rollArgs(name, target.region, target.projectId) as [string[], string[]];
|
|
@@ -15,7 +15,7 @@ const T = {
|
|
|
15
15
|
projectId: "acme",
|
|
16
16
|
region: "europe-west1",
|
|
17
17
|
env: "production",
|
|
18
|
-
|
|
18
|
+
proxyService: "caelo-production-gateway-abc",
|
|
19
19
|
};
|
|
20
20
|
const DAY = 24 * 60 * 60 * 1000;
|
|
21
21
|
const NOW = Date.UTC(2026, 9, 20);
|
|
@@ -122,7 +122,7 @@ describe("seal-key rotation (#806)", () => {
|
|
|
122
122
|
|
|
123
123
|
it("keeps the window equal to the broker's refresh-token lifetime", async () => {
|
|
124
124
|
const src = await Bun.file(
|
|
125
|
-
new URL("../../../apps/
|
|
125
|
+
new URL("../../../apps/mcp-auth-proxy/src/token.ts", import.meta.url),
|
|
126
126
|
).text();
|
|
127
127
|
expect(src).toContain(
|
|
128
128
|
`REFRESH_MAX_LIFETIME_SECONDS = ${SEAL_KEY_ROTATION_WINDOW_DAYS} * 24 * 60 * 60`,
|
package/src/seal-key-rotation.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* every connected MCP client out.
|
|
6
6
|
*
|
|
7
7
|
* The broker seals its OAuth state, codes and refresh tokens under
|
|
8
|
-
* `CAELO_MCP_OAUTH_SEAL_KEY` (
|
|
8
|
+
* `CAELO_MCP_OAUTH_SEAL_KEY` (apps/mcp-auth-proxy seal.ts, envelope v2 with a
|
|
9
9
|
* key id). While `CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS` is set it also opens
|
|
10
10
|
* values sealed under that key, and seals everything new under the current
|
|
11
11
|
* one. Refresh tokens live at most {@link SEAL_KEY_ROTATION_WINDOW_DAYS}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Two steps, both `cms-provision rotate-secret mcp-oauth-seal-key`:
|
|
16
16
|
*
|
|
17
|
-
* start — add a new version to the secret, then roll the
|
|
17
|
+
* start — add a new version to the secret, then roll the MCP-Auth-Proxy with
|
|
18
18
|
* the current key at `latest` and the previous key PINNED to
|
|
19
19
|
* the version that was latest before (a pinned version never
|
|
20
20
|
* follows the new one), labelled with the start time.
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* progress (a second start would pin the CURRENT key as "previous" and drop
|
|
28
28
|
* the real previous one early), and finishing before the window ends.
|
|
29
29
|
*
|
|
30
|
-
* A `cms-provision upgrade` whose `pulumi up` rewrites the
|
|
30
|
+
* A `cms-provision upgrade` whose `pulumi up` rewrites the MCP-Auth-Proxy template
|
|
31
31
|
* during the window drops the previous key (the stack does not declare it):
|
|
32
32
|
* clients sealed under it sign in again. That fails safe — no token lives
|
|
33
33
|
* longer than intended — and is documented in the rotate output.
|
|
@@ -39,27 +39,27 @@ import { gcpSecretId } from "./gcp-names.js";
|
|
|
39
39
|
import { initialSecretValue } from "./runtime-secrets.js";
|
|
40
40
|
import { liveContainerEnv } from "./stack-converge.js";
|
|
41
41
|
|
|
42
|
-
/** The
|
|
42
|
+
/** The MCP-Auth-Proxy env var holding the previous key during a rotation. */
|
|
43
43
|
export const SEAL_KEY_PREVIOUS_ENV = "CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS";
|
|
44
|
-
/** The
|
|
44
|
+
/** The MCP-Auth-Proxy env var holding the current key (stack-contract SERVICE_SECRET_ENV). */
|
|
45
45
|
export const SEAL_KEY_ENV = "CAELO_MCP_OAUTH_SEAL_KEY";
|
|
46
|
-
/** Label on the
|
|
46
|
+
/** Label on the MCP-Auth-Proxy service: when the rotation started (epoch ms). */
|
|
47
47
|
export const SEAL_ROTATION_LABEL = "caelo-seal-rotation-started";
|
|
48
48
|
/**
|
|
49
49
|
* Days the previous key must stay: the broker's refresh-token lifetime
|
|
50
|
-
* (
|
|
50
|
+
* (apps/mcp-auth-proxy token.ts REFRESH_MAX_LIFETIME_SECONDS, 30 days).
|
|
51
51
|
*/
|
|
52
52
|
export const SEAL_KEY_ROTATION_WINDOW_DAYS = 30;
|
|
53
53
|
|
|
54
54
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
55
55
|
|
|
56
|
-
/** The deployed
|
|
56
|
+
/** The deployed MCP-Auth-Proxy a rotation works on. */
|
|
57
57
|
export interface SealRotationTarget {
|
|
58
58
|
readonly projectId: string;
|
|
59
59
|
readonly region: string;
|
|
60
60
|
readonly env: string;
|
|
61
|
-
/** Deployed (Pulumi-suffixed)
|
|
62
|
-
readonly
|
|
61
|
+
/** Deployed (Pulumi-suffixed or fixed) MCP-Auth-Proxy Cloud Run service name. */
|
|
62
|
+
readonly proxyService: string;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export interface SealRotationReport {
|
|
@@ -80,7 +80,7 @@ interface GatewayState {
|
|
|
80
80
|
readonly startedAtMs: number | null;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
async function
|
|
83
|
+
async function proxyState(
|
|
84
84
|
run: GcloudRunner,
|
|
85
85
|
t: SealRotationTarget,
|
|
86
86
|
): Promise<{ ok: true; state: GatewayState } | { ok: false; error: string }> {
|
|
@@ -88,13 +88,13 @@ async function gatewayState(
|
|
|
88
88
|
"run",
|
|
89
89
|
"services",
|
|
90
90
|
"describe",
|
|
91
|
-
t.
|
|
91
|
+
t.proxyService,
|
|
92
92
|
"--region",
|
|
93
93
|
t.region,
|
|
94
94
|
`--project=${t.projectId}`,
|
|
95
95
|
"--format=json",
|
|
96
96
|
]);
|
|
97
|
-
if (!r.ok) return { ok: false, error: `read the
|
|
97
|
+
if (!r.ok) return { ok: false, error: `read the MCP-Auth-Proxy service: ${r.stderr.trim()}` };
|
|
98
98
|
const env = liveContainerEnv(r.stdout);
|
|
99
99
|
const labels =
|
|
100
100
|
(JSON.parse(r.stdout) as { metadata?: { labels?: Record<string, string> } }).metadata?.labels ??
|
|
@@ -105,7 +105,7 @@ async function gatewayState(
|
|
|
105
105
|
if (current?.kind !== "secret") {
|
|
106
106
|
return {
|
|
107
107
|
ok: false,
|
|
108
|
-
error: `${SEAL_KEY_ENV} on ${t.
|
|
108
|
+
error: `${SEAL_KEY_ENV} on ${t.proxyService} is not a Secret Manager reference — run \`cms-provision upgrade\` first (it installs remote MCP's secrets).`,
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
return {
|
|
@@ -119,7 +119,7 @@ function rollFlip(t: SealRotationTarget): string[] {
|
|
|
119
119
|
"run",
|
|
120
120
|
"services",
|
|
121
121
|
"update-traffic",
|
|
122
|
-
t.
|
|
122
|
+
t.proxyService,
|
|
123
123
|
"--region",
|
|
124
124
|
t.region,
|
|
125
125
|
`--project=${t.projectId}`,
|
|
@@ -140,7 +140,7 @@ export async function startSealKeyRotation(
|
|
|
140
140
|
const project = `--project=${t.projectId}`;
|
|
141
141
|
const steps: string[] = [];
|
|
142
142
|
|
|
143
|
-
const live = await
|
|
143
|
+
const live = await proxyState(run, t);
|
|
144
144
|
if (!live.ok) return { ok: false, steps, error: live.error };
|
|
145
145
|
if (live.state.previousPinned) {
|
|
146
146
|
const since = live.state.startedAtMs;
|
|
@@ -181,7 +181,7 @@ export async function startSealKeyRotation(
|
|
|
181
181
|
"run",
|
|
182
182
|
"services",
|
|
183
183
|
"update",
|
|
184
|
-
t.
|
|
184
|
+
t.proxyService,
|
|
185
185
|
"--region",
|
|
186
186
|
t.region,
|
|
187
187
|
project,
|
|
@@ -195,11 +195,11 @@ export async function startSealKeyRotation(
|
|
|
195
195
|
return {
|
|
196
196
|
ok: false,
|
|
197
197
|
steps,
|
|
198
|
-
error: `The new key is stored but the
|
|
198
|
+
error: `The new key is stored but the MCP-Auth-Proxy does not use it yet. Do not rotate again — roll the MCP-Auth-Proxy:\n gcloud ${update.join(" ")}\n gcloud ${rollFlip(t).join(" ")}\n(${flipped.stderr.trim()})`,
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
201
|
steps.push(
|
|
202
|
-
`rolled the
|
|
202
|
+
`rolled the MCP-Auth-Proxy onto the new key; tokens sealed under version ${version} keep working`,
|
|
203
203
|
`finish after ${new Date(now + SEAL_KEY_ROTATION_WINDOW_DAYS * DAY_MS).toISOString()} with: cms-provision rotate-secret mcp-oauth-seal-key --finish`,
|
|
204
204
|
"a `cms-provision upgrade` before then drops the previous key early: clients sealed under it sign in again",
|
|
205
205
|
);
|
|
@@ -215,13 +215,13 @@ export async function finishSealKeyRotation(
|
|
|
215
215
|
const run = deps.run ?? defaultGcloud;
|
|
216
216
|
const now = (deps.nowMs ?? Date.now)();
|
|
217
217
|
const steps: string[] = [];
|
|
218
|
-
const live = await
|
|
218
|
+
const live = await proxyState(run, t);
|
|
219
219
|
if (!live.ok) return { ok: false, steps, error: live.error };
|
|
220
220
|
if (!live.state.previousPinned) {
|
|
221
221
|
return {
|
|
222
222
|
ok: false,
|
|
223
223
|
steps,
|
|
224
|
-
error: `No seal-key rotation is in progress on ${t.
|
|
224
|
+
error: `No seal-key rotation is in progress on ${t.proxyService} (${SEAL_KEY_PREVIOUS_ENV} is not set). Start one with \`cms-provision rotate-secret mcp-oauth-seal-key\`.`,
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
227
|
const started = live.state.startedAtMs;
|
|
@@ -232,7 +232,7 @@ export async function finishSealKeyRotation(
|
|
|
232
232
|
steps,
|
|
233
233
|
error:
|
|
234
234
|
due === null
|
|
235
|
-
? `The
|
|
235
|
+
? `The MCP-Auth-Proxy has ${SEAL_KEY_PREVIOUS_ENV} but no ${SEAL_ROTATION_LABEL} label, so the rotation's start is unknown. Wait ${SEAL_KEY_ROTATION_WINDOW_DAYS} days from when it started, then run --finish --now.`
|
|
236
236
|
: `The previous key still opens refresh tokens until ${new Date(due).toISOString()} (${SEAL_KEY_ROTATION_WINDOW_DAYS}-day lifetime). Finishing now would sign those clients out; run --finish after that time, or --finish --now if the old key leaked.`,
|
|
237
237
|
};
|
|
238
238
|
}
|
|
@@ -240,7 +240,7 @@ export async function finishSealKeyRotation(
|
|
|
240
240
|
"run",
|
|
241
241
|
"services",
|
|
242
242
|
"update",
|
|
243
|
-
t.
|
|
243
|
+
t.proxyService,
|
|
244
244
|
"--region",
|
|
245
245
|
t.region,
|
|
246
246
|
`--project=${t.projectId}`,
|
|
@@ -251,8 +251,8 @@ export async function finishSealKeyRotation(
|
|
|
251
251
|
const rolled = await run(update);
|
|
252
252
|
const flipped = rolled.ok ? await run(rollFlip(t)) : rolled;
|
|
253
253
|
if (!flipped.ok) {
|
|
254
|
-
return { ok: false, steps, error: `roll the
|
|
254
|
+
return { ok: false, steps, error: `roll the MCP-Auth-Proxy: ${flipped.stderr.trim()}` };
|
|
255
255
|
}
|
|
256
|
-
steps.push("removed the previous seal key from the
|
|
256
|
+
steps.push("removed the previous seal key from the MCP-Auth-Proxy; the rotation is complete");
|
|
257
257
|
return { ok: true, steps };
|
|
258
258
|
}
|
|
@@ -6,6 +6,8 @@ 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,
|
|
10
12
|
type ComposeSpec,
|
|
11
13
|
composeProjectName,
|
|
@@ -22,6 +24,7 @@ const base: Omit<ComposeSpec, "images"> = {
|
|
|
22
24
|
postgresPassword: "pgpw",
|
|
23
25
|
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
24
26
|
caeloSecretKek: "0".repeat(64),
|
|
27
|
+
dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
|
|
25
28
|
internalSecret: "i".repeat(48),
|
|
26
29
|
anthropicApiKey: "sk-ant-x",
|
|
27
30
|
diskSize: "20Gi",
|
|
@@ -53,6 +56,7 @@ describe("self-hosted Compose — internal secret (#770)", () => {
|
|
|
53
56
|
expect(() =>
|
|
54
57
|
generateDockerCompose({
|
|
55
58
|
...base,
|
|
59
|
+
dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
|
|
56
60
|
internalSecret: "short",
|
|
57
61
|
images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
|
|
58
62
|
}),
|
|
@@ -68,10 +72,23 @@ describe("self-hosted Compose — connection budget (#1254)", () => {
|
|
|
68
72
|
|
|
69
73
|
it("starts Postgres with the max_connections the budget is computed from", () => {
|
|
70
74
|
expect(service(yaml, "postgres")).toContain(
|
|
71
|
-
'command: ["postgres", "-c", "max_connections=100"
|
|
75
|
+
'command: ["postgres", "-c", "max_connections=100", ',
|
|
72
76
|
);
|
|
73
77
|
});
|
|
74
78
|
|
|
79
|
+
it("serves TLS with the install's own certificate, never a key it does not own (#1338)", () => {
|
|
80
|
+
const pg = service(yaml, "postgres");
|
|
81
|
+
expect(pg).toContain('"-c", "ssl=on"');
|
|
82
|
+
expect(pg).toContain("- ./postgres-tls/server.crt:/caelo-tls/server.crt:ro");
|
|
83
|
+
expect(pg).toContain("- ./postgres-tls/server.key:/caelo-tls/server.key:ro");
|
|
84
|
+
// The bind mount keeps the host's owner; Postgres only reads its own key.
|
|
85
|
+
expect(pg).toContain("install -o postgres -g postgres -m 0600 /caelo-tls/server.key");
|
|
86
|
+
expect(pg).toContain('exec docker-entrypoint.sh \\"$$@\\"');
|
|
87
|
+
// Only Postgres sees the server key; the CA key never leaves .caelo/.
|
|
88
|
+
expect(yaml.replace(pg, "")).not.toContain("server.key");
|
|
89
|
+
expect(yaml).not.toContain("ca.key");
|
|
90
|
+
});
|
|
91
|
+
|
|
75
92
|
it("sizes the admin's two pools and the gateway's one", () => {
|
|
76
93
|
expect(service(yaml, "caelo-admin")).toContain(
|
|
77
94
|
` CAELO_DB_POOL_MAX: "${COMPOSE_POOL_BUDGET.budget.adminPoolMax}"`,
|
|
@@ -82,6 +99,26 @@ describe("self-hosted Compose — connection budget (#1254)", () => {
|
|
|
82
99
|
});
|
|
83
100
|
});
|
|
84
101
|
|
|
102
|
+
describe("self-hosted Compose — database certificate renewal (#1338)", () => {
|
|
103
|
+
const render = (digest: string) =>
|
|
104
|
+
generateDockerCompose({
|
|
105
|
+
...base,
|
|
106
|
+
dbTlsServerCertSha256: digest,
|
|
107
|
+
images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("carries the server certificate's digest on Postgres, so a renewal recreates it", () => {
|
|
111
|
+
const before = service(render("a".repeat(64)), "postgres");
|
|
112
|
+
const after = service(render("c".repeat(64)), "postgres");
|
|
113
|
+
expect(before).toContain(`CAELO_DB_TLS_SERVER_CERT_SHA256: "${"a".repeat(64)}"`);
|
|
114
|
+
expect(after).not.toBe(before);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("refuses a value that is no SHA-256 digest", () => {
|
|
118
|
+
expect(() => render("not-a-digest")).toThrow("dbTlsServerCertSha256");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
85
122
|
describe("self-hosted Compose — release images (#696)", () => {
|
|
86
123
|
const yaml = generateDockerCompose({
|
|
87
124
|
...base,
|
|
@@ -104,10 +141,13 @@ describe("self-hosted Compose — release images (#696)", () => {
|
|
|
104
141
|
it("mounts the bootstrap-token, site output + media dirs, never .caelo/ with its secrets", () => {
|
|
105
142
|
const admin = service(yaml, "caelo-admin");
|
|
106
143
|
expect(admin).toContain(
|
|
107
|
-
" volumes:\n - ./bootstrap:/app/.caelo\n - ./output:/srv/caelo\n - ./media:/data/media",
|
|
144
|
+
" 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
145
|
);
|
|
109
146
|
expect(yaml).not.toMatch(/- \.\/?:/);
|
|
110
|
-
|
|
147
|
+
// The gateway mounts the database CA (#1338) and nothing else.
|
|
148
|
+
expect(service(yaml, "caelo-gateway")).toContain(
|
|
149
|
+
" volumes:\n - ./postgres-tls/ca.crt:/etc/caelo/db-ca/self-hosted-ca.pem:ro\n restart:",
|
|
150
|
+
);
|
|
111
151
|
expect(admin).toContain('CAELO_OUTPUT_ROOT: "/srv/caelo"');
|
|
112
152
|
expect(admin).toContain('MEDIA_ROOT_DIR: "/data/media"');
|
|
113
153
|
expect(service(yaml, "caddy")).toContain("- ./output:/srv/caelo:ro");
|
|
@@ -116,10 +156,10 @@ describe("self-hosted Compose — release images (#696)", () => {
|
|
|
116
156
|
it("keeps the env + secret wiring of each service", () => {
|
|
117
157
|
const admin = service(yaml, "caelo-admin");
|
|
118
158
|
expect(admin).toContain(
|
|
119
|
-
'ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_admin"',
|
|
159
|
+
'ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/self-hosted-ca.pem"',
|
|
120
160
|
);
|
|
121
161
|
expect(admin).toContain(
|
|
122
|
-
'PUBLIC_ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_public"',
|
|
162
|
+
'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
163
|
);
|
|
124
164
|
expect(admin).toContain(`CAELO_SECRET_KEK: "${"0".repeat(64)}"`);
|
|
125
165
|
expect(admin).toContain('CAELO_SITE_URL: "https://example.com"');
|
|
@@ -307,3 +347,66 @@ describe("self-hosted Compose — the host update agent (#696 Phase 4c)", () =>
|
|
|
307
347
|
).toThrow(/between 1024 and 65535/);
|
|
308
348
|
});
|
|
309
349
|
});
|
|
350
|
+
|
|
351
|
+
describe("self-hosted Compose — the MCP-Auth-Proxy (#1328)", () => {
|
|
352
|
+
const PROXY = `ghcr.io/caelo-cms/mcp-auth-proxy@sha256:${"c".repeat(64)}`;
|
|
353
|
+
const yaml = generateDockerCompose({
|
|
354
|
+
...base,
|
|
355
|
+
images: { mode: "release", admin: ADMIN, gateway: GATEWAY, "mcp-auth-proxy": PROXY },
|
|
356
|
+
});
|
|
357
|
+
const proxy = service(yaml, COMPOSE_MCP_AUTH_PROXY_SERVICE);
|
|
358
|
+
|
|
359
|
+
it("runs the digest-pinned release image on the port Caddy proxies to", () => {
|
|
360
|
+
expect(proxy).toContain(`image: ${PROXY}`);
|
|
361
|
+
expect(proxy).toContain('MCP_AUTH_PROXY_PORT: "8095"');
|
|
362
|
+
expect(COMPOSE_MCP_AUTH_PROXY_UPSTREAM).toBe("caelo-mcp-auth-proxy:8095");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("says remote MCP is unavailable here and trusts Caddy as the one hop", () => {
|
|
366
|
+
expect(proxy).toContain('CAELO_REMOTE_MCP: "unavailable"');
|
|
367
|
+
expect(proxy).toContain('CAELO_TRUSTED_PROXY_HOPS: "1"');
|
|
368
|
+
expect(proxy).toContain('CAELO_PROVIDER: "self-hosted"');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("gets no database URL, no secret, no mount and no dependency on Postgres", () => {
|
|
372
|
+
expect(proxy).not.toMatch(/DATABASE|PASSWORD|SECRET|KEK|postgres:\/\/|volumes:|depends_on/);
|
|
373
|
+
expect(proxy).not.toContain("adminpw");
|
|
374
|
+
expect(proxy).not.toContain("publicpw");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("runs locked down: read-only root, no capabilities, no privilege escalation", () => {
|
|
378
|
+
expect(proxy).toContain("read_only: true");
|
|
379
|
+
expect(proxy).toContain('cap_drop: ["ALL"]');
|
|
380
|
+
expect(proxy).toContain('security_opt: ["no-new-privileges:true"]');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("is absent for a release recorded before the proxy shipped", () => {
|
|
384
|
+
const old = generateDockerCompose({
|
|
385
|
+
...base,
|
|
386
|
+
images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
|
|
387
|
+
});
|
|
388
|
+
expect(old).not.toContain(COMPOSE_MCP_AUTH_PROXY_SERVICE);
|
|
389
|
+
expect(old).toContain("\n\nvolumes:\n");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("runs the checkout's proxy in dev mode", () => {
|
|
393
|
+
const dev = generateDockerCompose({ ...base, images: { mode: "dev" } });
|
|
394
|
+
expect(service(dev, COMPOSE_MCP_AUTH_PROXY_SERVICE)).toContain(
|
|
395
|
+
"command: bun run apps/mcp-auth-proxy/src/server.ts",
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("refuses a proxy image that is not a digest-pinned GHCR reference", () => {
|
|
400
|
+
expect(() =>
|
|
401
|
+
generateDockerCompose({
|
|
402
|
+
...base,
|
|
403
|
+
images: {
|
|
404
|
+
mode: "release",
|
|
405
|
+
admin: ADMIN,
|
|
406
|
+
gateway: GATEWAY,
|
|
407
|
+
"mcp-auth-proxy": "ghcr.io/caelo-cms/mcp-auth-proxy:latest",
|
|
408
|
+
},
|
|
409
|
+
}),
|
|
410
|
+
).toThrow(/mcp-auth-proxy image/);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -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
|
+
});
|