@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1328 — resolving a self-hosted release: the MCP-Auth-Proxy image is
|
|
5
|
+
* optional only for a release that has none (the registry says its tag does
|
|
6
|
+
* not exist), never because the registry could not be asked.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { type HostRunner, resolveVerifiedRelease } from "./self-hosted-release.js";
|
|
11
|
+
|
|
12
|
+
const D = (c: string) => `sha256:${c.repeat(64)}`;
|
|
13
|
+
|
|
14
|
+
/** GHCR with a token challenge; `proxy` decides the MCP-Auth-Proxy tag's answer. */
|
|
15
|
+
function ghcr(proxy: "present" | 404 | 503): typeof fetch {
|
|
16
|
+
return (async (input: string | URL | Request, init?: RequestInit) => {
|
|
17
|
+
const url = String(input);
|
|
18
|
+
if (url.startsWith("https://ghcr.io/token")) return Response.json({ token: "t" });
|
|
19
|
+
if (!new Headers(init?.headers).get("authorization")) {
|
|
20
|
+
return new Response(null, {
|
|
21
|
+
status: 401,
|
|
22
|
+
headers: { "www-authenticate": 'Bearer realm="https://ghcr.io/token",service="ghcr.io"' },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (url.includes("/caelo-cms/mcp-auth-proxy/")) {
|
|
26
|
+
if (proxy !== "present") return new Response(null, { status: proxy });
|
|
27
|
+
return new Response(null, { status: 200, headers: { "docker-content-digest": D("c") } });
|
|
28
|
+
}
|
|
29
|
+
const digest = url.includes("/caelo-cms/admin/") ? D("a") : D("b");
|
|
30
|
+
return new Response(null, { status: 200, headers: { "docker-content-digest": digest } });
|
|
31
|
+
}) as typeof fetch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runner(): HostRunner & { calls: string[] } {
|
|
35
|
+
const calls: string[] = [];
|
|
36
|
+
return {
|
|
37
|
+
calls,
|
|
38
|
+
async run(argv) {
|
|
39
|
+
calls.push(argv.join(" "));
|
|
40
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("resolveVerifiedRelease (#1328)", () => {
|
|
46
|
+
it("resolves and verifies all three images of a release that ships the proxy", async () => {
|
|
47
|
+
const r = runner();
|
|
48
|
+
const res = await resolveVerifiedRelease("0.12.0", { runner: r, fetchImpl: ghcr("present") });
|
|
49
|
+
expect(res.ok).toBe(true);
|
|
50
|
+
if (!res.ok) return;
|
|
51
|
+
expect(res.images["mcp-auth-proxy"]).toBe(`ghcr.io/caelo-cms/mcp-auth-proxy@${D("c")}`);
|
|
52
|
+
expect(r.calls.filter((c) => c.startsWith("cosign verify"))).toHaveLength(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("a release from before the proxy (its tag does not exist) resolves without it", async () => {
|
|
56
|
+
const r = runner();
|
|
57
|
+
const res = await resolveVerifiedRelease("0.11.0", { runner: r, fetchImpl: ghcr(404) });
|
|
58
|
+
expect(res.ok).toBe(true);
|
|
59
|
+
if (!res.ok) return;
|
|
60
|
+
expect("mcp-auth-proxy" in res.images).toBe(false);
|
|
61
|
+
expect(res.images.admin).toBe(`ghcr.io/caelo-cms/admin@${D("a")}`);
|
|
62
|
+
expect(r.calls.filter((c) => c.startsWith("cosign verify"))).toHaveLength(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a registry outage on the proxy tag fails instead of installing without it", async () => {
|
|
66
|
+
const res = await resolveVerifiedRelease("0.12.0", { runner: runner(), fetchImpl: ghcr(503) });
|
|
67
|
+
expect(res.ok).toBe(false);
|
|
68
|
+
if (res.ok) return;
|
|
69
|
+
expect(res.error).toContain("mcp-auth-proxy:0.12.0");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* `.caelo/release.json` is the install metadata for self-hosted (the cloud
|
|
8
8
|
* providers keep theirs in `~/.caelo-<id>/install.json`): the release
|
|
9
|
-
* version, the digest-pinned
|
|
10
|
-
*
|
|
9
|
+
* version, the digest-pinned image references the Compose file runs (admin,
|
|
10
|
+
* gateway, and since #1328 the MCP-Auth-Proxy), and a short history of installs/upgrades/rollbacks. It holds no
|
|
11
11
|
* secrets (config.json does), so it is safe to read from tooling and
|
|
12
12
|
* backups; writes refuse secret material like install.json's do.
|
|
13
13
|
*
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
GHCR_NAMESPACE,
|
|
28
28
|
GHCR_REGISTRY,
|
|
29
29
|
RELEASE_SERVICES,
|
|
30
|
+
type ReleaseService,
|
|
30
31
|
resolveRegistryDigest,
|
|
31
32
|
} from "./upgrade/release-images.js";
|
|
32
33
|
|
|
@@ -103,7 +104,12 @@ export interface ReleaseHistoryEntry {
|
|
|
103
104
|
export interface SelfHostedRelease {
|
|
104
105
|
/** The release tag the images were resolved from (e.g. `0.10.43`). */
|
|
105
106
|
readonly version: string;
|
|
106
|
-
readonly images: {
|
|
107
|
+
readonly images: {
|
|
108
|
+
readonly admin: string;
|
|
109
|
+
readonly gateway: string;
|
|
110
|
+
/** #1328 — absent in records of releases from before the MCP-Auth-Proxy. */
|
|
111
|
+
readonly "mcp-auth-proxy"?: string;
|
|
112
|
+
};
|
|
107
113
|
readonly installedAt: string;
|
|
108
114
|
/** Newest last; capped at {@link HISTORY_LIMIT}. */
|
|
109
115
|
readonly history: readonly ReleaseHistoryEntry[];
|
|
@@ -117,7 +123,17 @@ export function releasePath(caeloDir: string): string {
|
|
|
117
123
|
|
|
118
124
|
/** The images of a recorded release, as the Compose generator takes them. */
|
|
119
125
|
export function releaseImages(release: SelfHostedRelease): ReleaseImages {
|
|
120
|
-
return { mode: "release",
|
|
126
|
+
return { mode: "release", ...release.images };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The image references of `images`, as release.json stores them. */
|
|
130
|
+
function recordedImages(images: ReleaseImages): SelfHostedRelease["images"] {
|
|
131
|
+
const proxy = images["mcp-auth-proxy"];
|
|
132
|
+
return {
|
|
133
|
+
admin: images.admin,
|
|
134
|
+
gateway: images.gateway,
|
|
135
|
+
...(proxy !== undefined ? { "mcp-auth-proxy": proxy } : {}),
|
|
136
|
+
};
|
|
121
137
|
}
|
|
122
138
|
|
|
123
139
|
/**
|
|
@@ -131,10 +147,12 @@ export function readRelease(caeloDir: string): SelfHostedRelease | null {
|
|
|
131
147
|
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<SelfHostedRelease>;
|
|
132
148
|
const admin = raw.images?.admin ?? "";
|
|
133
149
|
const gateway = raw.images?.gateway ?? "";
|
|
150
|
+
const proxy = raw.images?.["mcp-auth-proxy"];
|
|
134
151
|
if (
|
|
135
152
|
typeof raw.version !== "string" ||
|
|
136
153
|
!ghcrImageDigest("admin", admin) ||
|
|
137
|
-
!ghcrImageDigest("gateway", gateway)
|
|
154
|
+
!ghcrImageDigest("gateway", gateway) ||
|
|
155
|
+
(proxy !== undefined && !ghcrImageDigest("mcp-auth-proxy", proxy))
|
|
138
156
|
) {
|
|
139
157
|
throw new Error(
|
|
140
158
|
`${path} is malformed (needs version + digest-pinned ghcr.io/caelo-cms images); fix it or re-run \`${selfCommand("upgrade", "--version", "<x.y.z>")}\`.`,
|
|
@@ -142,7 +160,7 @@ export function readRelease(caeloDir: string): SelfHostedRelease | null {
|
|
|
142
160
|
}
|
|
143
161
|
return {
|
|
144
162
|
version: raw.version,
|
|
145
|
-
images: { admin, gateway },
|
|
163
|
+
images: { admin, gateway, ...(proxy !== undefined ? { "mcp-auth-proxy": proxy } : {}) },
|
|
146
164
|
installedAt: raw.installedAt ?? "",
|
|
147
165
|
history: Array.isArray(raw.history) ? raw.history : [],
|
|
148
166
|
};
|
|
@@ -162,7 +180,7 @@ export function recordRelease(
|
|
|
162
180
|
const at = now.toISOString();
|
|
163
181
|
const record: SelfHostedRelease = {
|
|
164
182
|
version: next.version,
|
|
165
|
-
images:
|
|
183
|
+
images: recordedImages(next.images),
|
|
166
184
|
installedAt:
|
|
167
185
|
prior && prior.version === next.version && entry.outcome === "rolled-back"
|
|
168
186
|
? prior.installedAt
|
|
@@ -195,7 +213,7 @@ export type VerifiedRelease =
|
|
|
195
213
|
| { readonly ok: false; readonly error: string };
|
|
196
214
|
|
|
197
215
|
/**
|
|
198
|
-
* Resolve
|
|
216
|
+
* Resolve every release image of `tag` (RELEASE_SERVICES) in GHCR to digests,
|
|
199
217
|
* then cosign-verify each digest against the Caelo release workflow
|
|
200
218
|
* identity (unless `skipVerify`, the escape hatch for forks). The returned
|
|
201
219
|
* references are digest-pinned, so what was verified is exactly what runs.
|
|
@@ -209,7 +227,7 @@ export async function resolveVerifiedRelease(
|
|
|
209
227
|
readonly onProgress?: (message: string) => void;
|
|
210
228
|
},
|
|
211
229
|
): Promise<VerifiedRelease> {
|
|
212
|
-
const refs: Partial<Record<
|
|
230
|
+
const refs: Partial<Record<ReleaseService, string>> = {};
|
|
213
231
|
for (const service of RELEASE_SERVICES) {
|
|
214
232
|
deps.onProgress?.(`Resolving ${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}:${tag}`);
|
|
215
233
|
const r = await resolveRegistryDigest(
|
|
@@ -218,6 +236,14 @@ export async function resolveVerifiedRelease(
|
|
|
218
236
|
tag,
|
|
219
237
|
deps.fetchImpl,
|
|
220
238
|
);
|
|
239
|
+
if (!r.ok && r.notFound && service === "mcp-auth-proxy") {
|
|
240
|
+
// #1328 — releases from before the MCP-Auth-Proxy ship no image of it,
|
|
241
|
+
// and they stay installable and rollback targets: their Compose file
|
|
242
|
+
// simply has no proxy (ReleaseImages models the absence). Only a
|
|
243
|
+
// registry verdict that the tag does not exist means that; an outage
|
|
244
|
+
// still fails below, never silently installs a release without it.
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
221
247
|
if (!r.ok) {
|
|
222
248
|
return {
|
|
223
249
|
ok: false,
|
|
@@ -228,10 +254,12 @@ export async function resolveVerifiedRelease(
|
|
|
228
254
|
}
|
|
229
255
|
refs[service] = ghcrImageRef(service, r.digest);
|
|
230
256
|
}
|
|
257
|
+
const proxy = refs["mcp-auth-proxy"];
|
|
231
258
|
const images: ReleaseImages = {
|
|
232
259
|
mode: "release",
|
|
233
260
|
admin: refs.admin ?? "",
|
|
234
261
|
gateway: refs.gateway ?? "",
|
|
262
|
+
...(proxy ? { "mcp-auth-proxy": proxy } : {}),
|
|
235
263
|
};
|
|
236
264
|
if (deps.skipVerify) return { ok: true, images };
|
|
237
265
|
|
|
@@ -246,13 +274,15 @@ export async function resolveVerifiedRelease(
|
|
|
246
274
|
};
|
|
247
275
|
}
|
|
248
276
|
for (const service of RELEASE_SERVICES) {
|
|
277
|
+
const ref = images[service];
|
|
278
|
+
if (ref === undefined) continue;
|
|
249
279
|
deps.onProgress?.(`Verifying the signature of ${service}`);
|
|
250
|
-
const v = await deps.runner.run(cosignVerifyArgv(
|
|
280
|
+
const v = await deps.runner.run(cosignVerifyArgv(ref));
|
|
251
281
|
if (v.exitCode !== 0) {
|
|
252
282
|
return {
|
|
253
283
|
ok: false,
|
|
254
284
|
error:
|
|
255
|
-
`cosign verify FAILED for ${
|
|
285
|
+
`cosign verify FAILED for ${ref}: its signature does not match the Caelo release workflow.\n` +
|
|
256
286
|
"Either the registry was compromised, the image is a fork's, or sigstore had an outage (retry).\n" +
|
|
257
287
|
`cosign: ${v.stderr.trim().slice(0, 500)}`,
|
|
258
288
|
};
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
4
6
|
import {
|
|
7
|
+
applyPublicRoleLimits,
|
|
5
8
|
convergeSelfHostedRoles,
|
|
6
9
|
decideUp,
|
|
10
|
+
PUBLIC_ROLE_LIMITS,
|
|
11
|
+
publicRoleLimitsScript,
|
|
7
12
|
roleConvergenceScript,
|
|
8
13
|
selfHostedBootstrapScript,
|
|
9
14
|
} from "./self-hosted-roles.js";
|
|
@@ -126,3 +131,52 @@ describe("decideUp — `cms-provision up` never switches URLs onto unapplied pas
|
|
|
126
131
|
expect(d).toMatchObject({ kind: "proceed", markConverged: false });
|
|
127
132
|
});
|
|
128
133
|
});
|
|
134
|
+
|
|
135
|
+
describe("public_role's limits (#1338, H3)", () => {
|
|
136
|
+
const statements = [
|
|
137
|
+
`ALTER ROLE public_role CONNECTION LIMIT ${PUBLIC_ROLE_LIMITS.connectionLimit};`,
|
|
138
|
+
`ALTER ROLE public_role SET statement_timeout = '${PUBLIC_ROLE_LIMITS.statementTimeout}';`,
|
|
139
|
+
`ALTER ROLE public_role SET idle_in_transaction_session_timeout = '${PUBLIC_ROLE_LIMITS.idleInTransactionSessionTimeout}';`,
|
|
140
|
+
`ALTER ROLE public_role SET work_mem = '${PUBLIC_ROLE_LIMITS.workMem}';`,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
it("are set on the first start, on every `up`, and alone before an upgrade's migrations", () => {
|
|
144
|
+
for (const script of [
|
|
145
|
+
selfHostedBootstrapScript({ agent: false }),
|
|
146
|
+
roleConvergenceScript(PASSWORDS),
|
|
147
|
+
publicRoleLimitsScript(),
|
|
148
|
+
]) {
|
|
149
|
+
for (const st of statements) expect(script).toContain(st);
|
|
150
|
+
}
|
|
151
|
+
// The pre-migration script touches nothing else (no role drop, no grant).
|
|
152
|
+
expect(publicRoleLimitsScript().split("\n").filter(Boolean)).toEqual([
|
|
153
|
+
"\\set ON_ERROR_STOP on",
|
|
154
|
+
...statements,
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("match the values packages/migrations/src/bootstrap.sh sets (dev, CI, Cloud SQL bootstrap)", () => {
|
|
159
|
+
const bootstrap = readFileSync(
|
|
160
|
+
resolve(import.meta.dir, "../../migrations/src/bootstrap.sh"),
|
|
161
|
+
"utf8",
|
|
162
|
+
);
|
|
163
|
+
for (const st of statements) expect(bootstrap).toContain(st);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("refuse to run against a stopped database", async () => {
|
|
167
|
+
let ran = false;
|
|
168
|
+
const r = await applyPublicRoleLimits({
|
|
169
|
+
probe: async () => false,
|
|
170
|
+
psql: async () => {
|
|
171
|
+
ran = true;
|
|
172
|
+
return { exitCode: 0, stderr: "" };
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
expect(r).toEqual({
|
|
176
|
+
ok: false,
|
|
177
|
+
reachable: false,
|
|
178
|
+
error: "the Postgres container is not running",
|
|
179
|
+
});
|
|
180
|
+
expect(ran).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/src/self-hosted-roles.ts
CHANGED
|
@@ -38,6 +38,46 @@ export const AGENT_DB_ROLE = "caelo_agent";
|
|
|
38
38
|
const APP_ROLES = ["admin_role", "public_role"] as const;
|
|
39
39
|
type AppRole = (typeof APP_ROLES)[number];
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* #1338 (gateway hardening H3) — what bounds public_role, the gateway's
|
|
43
|
+
* login, on the database: at most this many connections, no statement or
|
|
44
|
+
* idle transaction that runs away, little sort memory per query. The same
|
|
45
|
+
* values as packages/migrations/src/bootstrap.sh and the cms_admin
|
|
46
|
+
* migration that sets them where admin_role may (Cloud SQL); the role audit
|
|
47
|
+
* (packages/migrations role-audit.ts) refuses an install without them.
|
|
48
|
+
* Self-hosted admin_role has no CREATEROLE, so the superuser applies them
|
|
49
|
+
* here — on the volume's first start (bootstrap.sh), on every `up`, and
|
|
50
|
+
* before the migrations of every upgrade ({@link publicRoleLimitsScript}).
|
|
51
|
+
*/
|
|
52
|
+
export const PUBLIC_ROLE_LIMITS = {
|
|
53
|
+
connectionLimit: 30,
|
|
54
|
+
statementTimeout: "5s",
|
|
55
|
+
idleInTransactionSessionTimeout: "15s",
|
|
56
|
+
workMem: "1MB",
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
/** The statements that apply {@link PUBLIC_ROLE_LIMITS} (idempotent). */
|
|
60
|
+
function publicRoleLimitsSql(): string[] {
|
|
61
|
+
const l = PUBLIC_ROLE_LIMITS;
|
|
62
|
+
return [
|
|
63
|
+
`ALTER ROLE public_role CONNECTION LIMIT ${l.connectionLimit};`,
|
|
64
|
+
`ALTER ROLE public_role SET statement_timeout = '${l.statementTimeout}';`,
|
|
65
|
+
`ALTER ROLE public_role SET idle_in_transaction_session_timeout = '${l.idleInTransactionSessionTimeout}';`,
|
|
66
|
+
`ALTER ROLE public_role SET work_mem = '${l.workMem}';`,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The psql script (superuser) that applies only {@link PUBLIC_ROLE_LIMITS}.
|
|
72
|
+
* The self-hosted upgrade runs it BEFORE the migrations: their role audit
|
|
73
|
+
* refuses public_role without the limits, and the full convergence must
|
|
74
|
+
* wait until after them (it drops roles a migration retired). Fails when
|
|
75
|
+
* public_role does not exist — the install's roles were never created.
|
|
76
|
+
*/
|
|
77
|
+
export function publicRoleLimitsScript(): string {
|
|
78
|
+
return ["\\set ON_ERROR_STOP on", ...publicRoleLimitsSql(), ""].join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
41
81
|
/** How a role's password appears in the SQL: a literal, or a psql variable. */
|
|
42
82
|
type PasswordSql = (role: AppRole | typeof AGENT_DB_ROLE) => string;
|
|
43
83
|
|
|
@@ -52,6 +92,7 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
|
|
|
52
92
|
`SELECT 'CREATE ROLE ${role} NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') \\gexec`,
|
|
53
93
|
`ALTER ROLE ${role} WITH LOGIN PASSWORD ${password(role)};`,
|
|
54
94
|
]),
|
|
95
|
+
...publicRoleLimitsSql(),
|
|
55
96
|
// The upgrader's role (migration 0267 creates it only with CREATEROLE,
|
|
56
97
|
// which the self-hosted admin_role lacks).
|
|
57
98
|
"SELECT 'CREATE ROLE platform_updater NOLOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'platform_updater') \\gexec",
|
|
@@ -143,6 +184,23 @@ ${convergenceSql((role) => `:'${role}_pw'`, opts.agent)}CAELO_SQL
|
|
|
143
184
|
/** Runs a psql script as the superuser; resolves to psql's exit code + stderr. */
|
|
144
185
|
export type SuperuserPsql = (script: string) => Promise<{ exitCode: number; stderr: string }>;
|
|
145
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Apply {@link publicRoleLimitsScript} through the running Postgres
|
|
189
|
+
* container. Unlike {@link convergeSelfHostedRoles} a stopped database is a
|
|
190
|
+
* failure: the upgrade runs it right before migrations that need it.
|
|
191
|
+
*/
|
|
192
|
+
export async function applyPublicRoleLimits(deps: {
|
|
193
|
+
readonly probe: () => Promise<boolean>;
|
|
194
|
+
readonly psql: SuperuserPsql;
|
|
195
|
+
}): Promise<ConvergeOutcome> {
|
|
196
|
+
if (!(await deps.probe())) {
|
|
197
|
+
return { ok: false, reachable: false, error: "the Postgres container is not running" };
|
|
198
|
+
}
|
|
199
|
+
const r = await deps.psql(publicRoleLimitsScript());
|
|
200
|
+
if (r.exitCode === 0) return { ok: true };
|
|
201
|
+
return { ok: false, reachable: true, error: r.stderr.trim() || `psql exited ${r.exitCode}` };
|
|
202
|
+
}
|
|
203
|
+
|
|
146
204
|
export type ConvergeOutcome =
|
|
147
205
|
| { readonly ok: true }
|
|
148
206
|
/** `reachable: false` — the database is not running, nothing was attempted. */
|