@wtfalch/foundry 0.37.0 → 0.39.0
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/bootstrap/versions.d.ts +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/plan/backup-gate.d.ts +19 -3
- package/dist/plan/backup-gate.js +49 -4
- package/dist/plan/company-backups.d.ts +7 -2
- package/dist/plan/company-backups.js +25 -6
- package/dist/plan/diff.d.ts +4 -0
- package/dist/plan/diff.js +19 -1
- package/dist/plan/executor.d.ts +13 -0
- package/dist/plan/executor.js +56 -13
- package/dist/plan/index.d.ts +2 -1
- package/dist/plan/index.js +1 -0
- package/dist/plan/mail-store.d.ts +34 -0
- package/dist/plan/mail-store.js +49 -0
- package/dist/plan/own-diff.d.ts +26 -0
- package/dist/plan/own-diff.js +74 -6
- package/dist/plan/own-services.d.ts +11 -0
- package/dist/plan/own-services.js +12 -0
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/plan-apply.js +30 -4
- package/dist/plan/platform-diff.d.ts +30 -0
- package/dist/plan/platform-diff.js +62 -7
- package/dist/plan/platform-org.d.ts +15 -0
- package/dist/plan/platform-org.js +17 -1
- package/dist/plan/platform-providers.d.ts +69 -2
- package/dist/plan/platform-providers.js +255 -41
- package/dist/plan/signin-db-backup.d.ts +105 -0
- package/dist/plan/signin-db-backup.js +193 -0
- package/dist/plan/signin-db-move.d.ts +68 -0
- package/dist/plan/signin-db-move.js +338 -0
- package/dist/plan/signin-db-tls.d.ts +69 -0
- package/dist/plan/signin-db-tls.js +255 -0
- package/dist/plan/signin-db.d.ts +114 -0
- package/dist/plan/signin-db.js +255 -0
- package/dist/plan/uptime-worker.d.ts +20 -1
- package/dist/plan/uptime-worker.js +121 -3
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- package/versions.json +2 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The certificate authority and server certificate of a company's sign-in
|
|
3
|
+
* database (#746). One CA serves the whole platform and is created once; each
|
|
4
|
+
* company's database gets its own server certificate, signed by it, for the
|
|
5
|
+
* database's internal hostname. Both are held in `@wtfalch/keys` as one JSON
|
|
6
|
+
* value each (`{ cert, key }`), so a CA is never half written.
|
|
7
|
+
*
|
|
8
|
+
* Node has no certificate builder, so the DER is written here: ECDSA P-256,
|
|
9
|
+
* SHA-256, X.509 v3, only the extensions Postgres clients check.
|
|
10
|
+
*/
|
|
11
|
+
/** The platform's one CA, held as JSON `{ cert, key }` (PEM strings). */
|
|
12
|
+
export declare const SIGNIN_DB_CA_REF = "signin-db:platform:ca";
|
|
13
|
+
/** The recorded id of the server certificate's expiry (milliseconds since the epoch), so the planner can renew it. Not a secret. */
|
|
14
|
+
export declare const SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY = "signin_db_cert_not_after";
|
|
15
|
+
/** Recorded id of the database app. */
|
|
16
|
+
export declare const SIGNIN_DB_APP_SCOPE_KEY = "signin_db_app";
|
|
17
|
+
/**
|
|
18
|
+
* Recorded once ZITADEL's DSN names the TLS database: from then on the sign-in step
|
|
19
|
+
* provisions this database and never the Coolify one.
|
|
20
|
+
*/
|
|
21
|
+
export declare const SIGNIN_DB_TLS_SCOPE_KEY = "signin_db_tls";
|
|
22
|
+
/**
|
|
23
|
+
* Recorded while ZITADEL has been started on the TLS database and not yet seen healthy there: the
|
|
24
|
+
* planner then plans the sign-in step, which starts ZITADEL again and clears it (#746).
|
|
25
|
+
*/
|
|
26
|
+
export declare const SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY = "signin_db_zitadel_pending";
|
|
27
|
+
/** A server certificate with less than this left is issued again. */
|
|
28
|
+
export declare const SIGNIN_DB_CERT_RENEW_WINDOW_MS: number;
|
|
29
|
+
export interface SigninDbKeys {
|
|
30
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
31
|
+
put?(ref: string, value: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export interface SigninDbTlsRuntime {
|
|
34
|
+
/** Called with every secret before it is stored or used, so no message or audit row can carry it. */
|
|
35
|
+
onSecret?: (value: string) => void;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
}
|
|
38
|
+
export interface PemPair {
|
|
39
|
+
certPem: string;
|
|
40
|
+
keyPem: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ServerCert extends PemPair {
|
|
43
|
+
/** Milliseconds since the epoch. */
|
|
44
|
+
notAfter: number;
|
|
45
|
+
/** True when this call made a new certificate. */
|
|
46
|
+
issued: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The platform's CA: the held one, else a new one stored under
|
|
50
|
+
* {@link SIGNIN_DB_CA_REF}. A held value that cannot be read, or whose
|
|
51
|
+
* certificate does not match its key, is an error and is never replaced: a
|
|
52
|
+
* company's database may already trust it. The value read back after a store
|
|
53
|
+
* is the one used, so two applies at once end with the same CA.
|
|
54
|
+
*/
|
|
55
|
+
export declare function ensureSigninDbCa(keys: SigninDbKeys, runtime?: SigninDbTlsRuntime,
|
|
56
|
+
/** A company already has a database or certificate made under the CA: a lost CA is then an error, never made again. */
|
|
57
|
+
inUse?: boolean): Promise<PemPair>;
|
|
58
|
+
/**
|
|
59
|
+
* The server certificate held under `ref` for `host`, issued again when it is
|
|
60
|
+
* missing, unreadable, for another host, signed by another CA, or has less
|
|
61
|
+
* than {@link SIGNIN_DB_CERT_RENEW_WINDOW_MS} left. The read-back after a
|
|
62
|
+
* store is the one used.
|
|
63
|
+
*/
|
|
64
|
+
export declare function ensureSigninDbServerCert(keys: SigninDbKeys, ca: PemPair, ref: string, host: string, runtime?: SigninDbTlsRuntime): Promise<ServerCert>;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the recorded server certificate expires within the renew window.
|
|
67
|
+
* Nothing recorded (a company not on the TLS database) is never due.
|
|
68
|
+
*/
|
|
69
|
+
export declare function signinDbCertDue(recordedIds: Readonly<Record<string, string>>, now: number): boolean;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { X509Certificate, createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* The certificate authority and server certificate of a company's sign-in
|
|
4
|
+
* database (#746). One CA serves the whole platform and is created once; each
|
|
5
|
+
* company's database gets its own server certificate, signed by it, for the
|
|
6
|
+
* database's internal hostname. Both are held in `@wtfalch/keys` as one JSON
|
|
7
|
+
* value each (`{ cert, key }`), so a CA is never half written.
|
|
8
|
+
*
|
|
9
|
+
* Node has no certificate builder, so the DER is written here: ECDSA P-256,
|
|
10
|
+
* SHA-256, X.509 v3, only the extensions Postgres clients check.
|
|
11
|
+
*/
|
|
12
|
+
/** The platform's one CA, held as JSON `{ cert, key }` (PEM strings). */
|
|
13
|
+
export const SIGNIN_DB_CA_REF = 'signin-db:platform:ca';
|
|
14
|
+
/** The recorded id of the server certificate's expiry (milliseconds since the epoch), so the planner can renew it. Not a secret. */
|
|
15
|
+
export const SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY = 'signin_db_cert_not_after';
|
|
16
|
+
/** Recorded id of the database app. */
|
|
17
|
+
export const SIGNIN_DB_APP_SCOPE_KEY = 'signin_db_app';
|
|
18
|
+
/**
|
|
19
|
+
* Recorded once ZITADEL's DSN names the TLS database: from then on the sign-in step
|
|
20
|
+
* provisions this database and never the Coolify one.
|
|
21
|
+
*/
|
|
22
|
+
export const SIGNIN_DB_TLS_SCOPE_KEY = 'signin_db_tls';
|
|
23
|
+
/**
|
|
24
|
+
* Recorded while ZITADEL has been started on the TLS database and not yet seen healthy there: the
|
|
25
|
+
* planner then plans the sign-in step, which starts ZITADEL again and clears it (#746).
|
|
26
|
+
*/
|
|
27
|
+
export const SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY = 'signin_db_zitadel_pending';
|
|
28
|
+
/** A server certificate with less than this left is issued again. */
|
|
29
|
+
export const SIGNIN_DB_CERT_RENEW_WINDOW_MS = 30 * 24 * 3_600_000;
|
|
30
|
+
const SERVER_CERT_VALIDITY_MS = 365 * 24 * 3_600_000;
|
|
31
|
+
const CA_VALIDITY_MS = 10 * 365 * 24 * 3_600_000;
|
|
32
|
+
/** The CA's subject. */
|
|
33
|
+
const CA_COMMON_NAME = 'foundry sign-in database CA';
|
|
34
|
+
// ---- DER ---------------------------------------------------------------------
|
|
35
|
+
const der = (tag, ...parts) => {
|
|
36
|
+
const body = Buffer.concat(parts);
|
|
37
|
+
const n = body.length;
|
|
38
|
+
const len = n < 0x80
|
|
39
|
+
? Buffer.from([n])
|
|
40
|
+
: n < 0x100
|
|
41
|
+
? Buffer.from([0x81, n])
|
|
42
|
+
: Buffer.from([0x82, n >> 8, n & 0xff]);
|
|
43
|
+
return Buffer.concat([Buffer.from([tag]), len, body]);
|
|
44
|
+
};
|
|
45
|
+
const seq = (...p) => der(0x30, ...p);
|
|
46
|
+
const set = (...p) => der(0x31, ...p);
|
|
47
|
+
const octets = (b) => der(0x04, b);
|
|
48
|
+
const bool = (v) => der(0x01, Buffer.from([v ? 0xff : 0x00]));
|
|
49
|
+
const int = (n) => {
|
|
50
|
+
const raw = typeof n === 'number' ? Buffer.from([n]) : n;
|
|
51
|
+
return der(0x02, raw[0] !== undefined && raw[0] >= 0x80 ? Buffer.concat([Buffer.from([0]), raw]) : raw);
|
|
52
|
+
};
|
|
53
|
+
const oid = (dotted) => {
|
|
54
|
+
const [a = 0, b = 0, ...rest] = dotted.split('.').map(Number);
|
|
55
|
+
const bytes = [a * 40 + b];
|
|
56
|
+
for (const n of rest) {
|
|
57
|
+
const chunk = [n & 0x7f];
|
|
58
|
+
for (let v = n >> 7; v > 0; v >>= 7)
|
|
59
|
+
chunk.unshift((v & 0x7f) | 0x80);
|
|
60
|
+
bytes.push(...chunk);
|
|
61
|
+
}
|
|
62
|
+
return der(0x06, Buffer.from(bytes));
|
|
63
|
+
};
|
|
64
|
+
const utf8 = (s) => der(0x0c, Buffer.from(s, 'utf8'));
|
|
65
|
+
/** RFC 5280 4.1.2.5: UTCTime up to the year 2049, GeneralizedTime from 2050. */
|
|
66
|
+
const utc = (ms) => {
|
|
67
|
+
const d = new Date(ms);
|
|
68
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
69
|
+
const year = d.getUTCFullYear();
|
|
70
|
+
const rest = `${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
|
|
71
|
+
return year >= 2050
|
|
72
|
+
? der(0x18, Buffer.from(`${year}${rest}`))
|
|
73
|
+
: der(0x17, Buffer.from(`${p(year % 100)}${rest}`));
|
|
74
|
+
};
|
|
75
|
+
const name = (cn) => seq(set(seq(oid('2.5.4.3'), utf8(cn))));
|
|
76
|
+
const extension = (id, critical, value) => seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
|
|
77
|
+
const ECDSA_SHA256 = '1.2.840.10045.4.3.2';
|
|
78
|
+
const pem = (label, derBytes) => {
|
|
79
|
+
const lines = derBytes.toString('base64').match(/.{1,64}/g) ?? [];
|
|
80
|
+
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
|
81
|
+
};
|
|
82
|
+
/** The hash of the 65-byte public point, the way RFC 5280 names a key. */
|
|
83
|
+
const keyId = (publicKey) => createHash('sha1')
|
|
84
|
+
.update(publicKey.export({ type: 'spki', format: 'der' }).subarray(-65))
|
|
85
|
+
.digest();
|
|
86
|
+
function certificate(input) {
|
|
87
|
+
const serial = randomBytes(16);
|
|
88
|
+
// 0x40 bit set, 0x80 clear: positive, and no leading zero byte (DER rejects a padded INTEGER).
|
|
89
|
+
serial[0] = ((serial[0] ?? 0) & 0x3f) | 0x40;
|
|
90
|
+
const sigAlg = seq(oid(ECDSA_SHA256));
|
|
91
|
+
const tbs = seq(der(0xa0, int(2)), int(serial), sigAlg, name(input.issuer), seq(utc(input.notBefore), utc(input.notAfter)), name(input.subject), Buffer.from(input.publicKey.export({ type: 'spki', format: 'der' })), der(0xa3, seq(...input.extensions)));
|
|
92
|
+
const signature = sign('sha256', tbs, input.signingKey);
|
|
93
|
+
return pem('CERTIFICATE', seq(tbs, sigAlg, der(0x03, Buffer.concat([Buffer.from([0]), signature]))));
|
|
94
|
+
}
|
|
95
|
+
const subjectKeyId = (publicKey) => extension('2.5.29.14', false, octets(keyId(publicKey)));
|
|
96
|
+
const authorityKeyId = (publicKey) => extension('2.5.29.35', false, seq(der(0x80, keyId(publicKey))));
|
|
97
|
+
function newKeyPair() {
|
|
98
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
99
|
+
return {
|
|
100
|
+
publicKey,
|
|
101
|
+
privateKey,
|
|
102
|
+
keyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// ---- the CA ------------------------------------------------------------------
|
|
106
|
+
function newCa(now) {
|
|
107
|
+
const { publicKey, privateKey, keyPem } = newKeyPair();
|
|
108
|
+
const certPem = certificate({
|
|
109
|
+
subject: CA_COMMON_NAME,
|
|
110
|
+
issuer: CA_COMMON_NAME,
|
|
111
|
+
publicKey,
|
|
112
|
+
signingKey: privateKey,
|
|
113
|
+
notBefore: now - 3_600_000,
|
|
114
|
+
notAfter: now + CA_VALIDITY_MS,
|
|
115
|
+
extensions: [
|
|
116
|
+
extension('2.5.29.19', true, seq(bool(true), int(0))),
|
|
117
|
+
extension('2.5.29.15', true, der(0x03, Buffer.from([0x01, 0x06]))),
|
|
118
|
+
subjectKeyId(publicKey),
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
return { certPem, keyPem };
|
|
122
|
+
}
|
|
123
|
+
function readPair(held) {
|
|
124
|
+
if (!held)
|
|
125
|
+
return null;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(held);
|
|
128
|
+
if (typeof parsed.cert !== 'string' || typeof parsed.key !== 'string')
|
|
129
|
+
return null;
|
|
130
|
+
return { certPem: parsed.cert, keyPem: parsed.key };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** True when `pair` is a certificate with its own private key. */
|
|
137
|
+
function pairMatches(pair) {
|
|
138
|
+
try {
|
|
139
|
+
return new X509Certificate(pair.certPem).checkPrivateKey(createPrivateKey(pair.keyPem));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The platform's CA: the held one, else a new one stored under
|
|
147
|
+
* {@link SIGNIN_DB_CA_REF}. A held value that cannot be read, or whose
|
|
148
|
+
* certificate does not match its key, is an error and is never replaced: a
|
|
149
|
+
* company's database may already trust it. The value read back after a store
|
|
150
|
+
* is the one used, so two applies at once end with the same CA.
|
|
151
|
+
*/
|
|
152
|
+
export async function ensureSigninDbCa(keys, runtime = {},
|
|
153
|
+
/** A company already has a database or certificate made under the CA: a lost CA is then an error, never made again. */
|
|
154
|
+
inUse = false) {
|
|
155
|
+
const held = await keys.resolve(SIGNIN_DB_CA_REF);
|
|
156
|
+
if (held) {
|
|
157
|
+
const pair = readPair(held);
|
|
158
|
+
if (!pair)
|
|
159
|
+
throw new Error('the held sign-in database CA cannot be read; it is not replaced');
|
|
160
|
+
runtime.onSecret?.(pair.keyPem);
|
|
161
|
+
if (!pairMatches(pair)) {
|
|
162
|
+
throw new Error('the held sign-in database CA is not a certificate with its own key; it is not replaced');
|
|
163
|
+
}
|
|
164
|
+
return pair;
|
|
165
|
+
}
|
|
166
|
+
if (inUse) {
|
|
167
|
+
throw new Error(`the sign-in database CA (${SIGNIN_DB_CA_REF}) is not held, but a company's database already trusts it; a new CA would break every company's sign-in, so none is made. Restore the CA from the keys backup (docs/runbooks/signin-db-tls.md, "A lost CA")`);
|
|
168
|
+
}
|
|
169
|
+
if (!keys.put)
|
|
170
|
+
throw new Error('the host cannot store the sign-in database CA: heldKeys has no put');
|
|
171
|
+
const created = newCa((runtime.now ?? Date.now)());
|
|
172
|
+
runtime.onSecret?.(created.keyPem);
|
|
173
|
+
await keys.put(SIGNIN_DB_CA_REF, JSON.stringify({ cert: created.certPem, key: created.keyPem }));
|
|
174
|
+
const stored = readPair(await keys.resolve(SIGNIN_DB_CA_REF));
|
|
175
|
+
if (!stored)
|
|
176
|
+
throw new Error('the sign-in database CA was stored but cannot be read back');
|
|
177
|
+
runtime.onSecret?.(stored.keyPem);
|
|
178
|
+
return stored;
|
|
179
|
+
}
|
|
180
|
+
// ---- the server certificate ----------------------------------------------------
|
|
181
|
+
function newServerCert(ca, host, now) {
|
|
182
|
+
const { publicKey, keyPem } = newKeyPair();
|
|
183
|
+
const caCert = new X509Certificate(ca.certPem);
|
|
184
|
+
const notAfter = now + SERVER_CERT_VALIDITY_MS;
|
|
185
|
+
const certPem = certificate({
|
|
186
|
+
subject: host,
|
|
187
|
+
issuer: CA_COMMON_NAME,
|
|
188
|
+
publicKey,
|
|
189
|
+
signingKey: createPrivateKey(ca.keyPem),
|
|
190
|
+
notBefore: now - 3_600_000,
|
|
191
|
+
notAfter,
|
|
192
|
+
extensions: [
|
|
193
|
+
extension('2.5.29.19', true, seq()),
|
|
194
|
+
extension('2.5.29.15', true, der(0x03, Buffer.from([0x07, 0x80]))),
|
|
195
|
+
extension('2.5.29.37', false, seq(oid('1.3.6.1.5.5.7.3.1'))),
|
|
196
|
+
extension('2.5.29.17', false, seq(der(0x82, Buffer.from(host, 'ascii')))),
|
|
197
|
+
subjectKeyId(publicKey),
|
|
198
|
+
authorityKeyId(caCert.publicKey),
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
return { certPem, keyPem, notAfter };
|
|
202
|
+
}
|
|
203
|
+
/** Whether a held server certificate can still be used: right host, our CA, a matching key, and more than the renew window left. */
|
|
204
|
+
function reusable(pair, ca, host, now) {
|
|
205
|
+
try {
|
|
206
|
+
const cert = new X509Certificate(pair.certPem);
|
|
207
|
+
const notAfter = cert.validToDate.getTime();
|
|
208
|
+
const ok = cert.checkPrivateKey(createPrivateKey(pair.keyPem)) &&
|
|
209
|
+
cert.checkHost(host) === host &&
|
|
210
|
+
cert.verify(createPublicKey(ca.certPem)) &&
|
|
211
|
+
notAfter - now > SIGNIN_DB_CERT_RENEW_WINDOW_MS &&
|
|
212
|
+
cert.validFromDate.getTime() <= now;
|
|
213
|
+
return ok ? notAfter : null;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The server certificate held under `ref` for `host`, issued again when it is
|
|
221
|
+
* missing, unreadable, for another host, signed by another CA, or has less
|
|
222
|
+
* than {@link SIGNIN_DB_CERT_RENEW_WINDOW_MS} left. The read-back after a
|
|
223
|
+
* store is the one used.
|
|
224
|
+
*/
|
|
225
|
+
export async function ensureSigninDbServerCert(keys, ca, ref, host, runtime = {}) {
|
|
226
|
+
const now = (runtime.now ?? Date.now)();
|
|
227
|
+
const held = readPair(await keys.resolve(ref));
|
|
228
|
+
if (held) {
|
|
229
|
+
runtime.onSecret?.(held.keyPem);
|
|
230
|
+
const notAfter = reusable(held, ca, host, now);
|
|
231
|
+
if (notAfter !== null)
|
|
232
|
+
return { ...held, notAfter, issued: false };
|
|
233
|
+
}
|
|
234
|
+
if (!keys.put)
|
|
235
|
+
throw new Error('the host cannot store the sign-in database certificate: heldKeys has no put');
|
|
236
|
+
const created = newServerCert(ca, host, now);
|
|
237
|
+
runtime.onSecret?.(created.keyPem);
|
|
238
|
+
await keys.put(ref, JSON.stringify({ cert: created.certPem, key: created.keyPem }));
|
|
239
|
+
const stored = readPair(await keys.resolve(ref));
|
|
240
|
+
if (!stored)
|
|
241
|
+
throw new Error('the sign-in database certificate was stored but cannot be read back');
|
|
242
|
+
runtime.onSecret?.(stored.keyPem);
|
|
243
|
+
const notAfter = reusable(stored, ca, host, now);
|
|
244
|
+
if (notAfter === null)
|
|
245
|
+
throw new Error('the stored sign-in database certificate is not usable');
|
|
246
|
+
return { ...stored, notAfter, issued: true };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Whether the recorded server certificate expires within the renew window.
|
|
250
|
+
* Nothing recorded (a company not on the TLS database) is never due.
|
|
251
|
+
*/
|
|
252
|
+
export function signinDbCertDue(recordedIds, now) {
|
|
253
|
+
const notAfter = Number(recordedIds[SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY]);
|
|
254
|
+
return (Number.isFinite(notAfter) && notAfter > 0 && notAfter - now < SIGNIN_DB_CERT_RENEW_WINDOW_MS);
|
|
255
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { CoolifyPort } from './executor.js';
|
|
2
|
+
import { SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, type SigninDbBackupPort } from './signin-db-backup.js';
|
|
3
|
+
import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, type SigninDbKeys, type SigninDbTlsRuntime } from './signin-db-tls.js';
|
|
4
|
+
/**
|
|
5
|
+
* A company's sign-in database as foundry's own image (#746). Coolify 4.4.2
|
|
6
|
+
* cannot turn on a database's TLS and mounts files root-owned, so Postgres
|
|
7
|
+
* would refuse a mounted key. The database is therefore a Dockerfile app: the
|
|
8
|
+
* pinned `postgres` image, an entrypoint that writes the certificate and key
|
|
9
|
+
* from env to files owned by `postgres` with mode 0600, and Postgres started
|
|
10
|
+
* with `ssl=on`. ZITADEL connects with `sslmode=verify-full` and the CA.
|
|
11
|
+
*/
|
|
12
|
+
export { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY };
|
|
13
|
+
export declare const SIGNIN_DB_PORT = "5432";
|
|
14
|
+
export declare const SIGNIN_DB_USER = "zitadel";
|
|
15
|
+
export declare const SIGNIN_DB_NAME = "zitadel";
|
|
16
|
+
/** Where the official image keeps its data (`PGDATA`); the volume is mounted here. */
|
|
17
|
+
export declare const SIGNIN_DB_DATA_DIR = "/var/lib/postgresql/data";
|
|
18
|
+
/** Where ZITADEL's container finds the CA (a Coolify file mount: the CA is public, and its image has no shell to write it). */
|
|
19
|
+
export declare const SIGNIN_DB_CA_MOUNT = "/etc/signin-db/ca.crt";
|
|
20
|
+
export declare const SIGNIN_DB_ENV: {
|
|
21
|
+
readonly user: "POSTGRES_USER";
|
|
22
|
+
readonly password: "POSTGRES_PASSWORD";
|
|
23
|
+
readonly database: "POSTGRES_DB";
|
|
24
|
+
readonly cert: "SIGNIN_DB_TLS_CERT_B64";
|
|
25
|
+
readonly key: "SIGNIN_DB_TLS_KEY_B64";
|
|
26
|
+
};
|
|
27
|
+
/** How long one Coolify call of these steps may take: the client has no timeout of its own. */
|
|
28
|
+
export declare const SIGNIN_DB_CALL_TIMEOUT_MS = 60000;
|
|
29
|
+
/** `work`, or an error naming `what` after {@link SIGNIN_DB_CALL_TIMEOUT_MS}. The call itself is not cancelled. */
|
|
30
|
+
export declare function boundedCall<T>(work: Promise<T>, what: string): Promise<T>;
|
|
31
|
+
/** The port with every method bounded by {@link boundedCall}. */
|
|
32
|
+
export declare function withCallTimeout<T extends object>(port: T): T;
|
|
33
|
+
/**
|
|
34
|
+
* The entrypoint, one line each. No line holds a single quote: the Dockerfile
|
|
35
|
+
* writes each one inside single quotes. The key file is written by `printf` to
|
|
36
|
+
* a path only root can reach and then handed to `postgres` with mode 0600, the
|
|
37
|
+
* check Postgres makes before it loads a key.
|
|
38
|
+
*/
|
|
39
|
+
export declare function signinDbStartScript(): string[];
|
|
40
|
+
/**
|
|
41
|
+
* The inline Dockerfile. `local` connections stay trusted (inside the container only, as in the
|
|
42
|
+
* official image); every network connection must be TLS with a password, so a client that skips
|
|
43
|
+
* TLS is refused by the server, not only by the client's own setting.
|
|
44
|
+
*/
|
|
45
|
+
export declare function signinDbDockerfile(): string;
|
|
46
|
+
/** ZITADEL's DSN: the whole chain is checked, host name included. A secret (it holds the password). */
|
|
47
|
+
export declare function signinDbDsn(host: string, password: string): string;
|
|
48
|
+
type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'raw'> & Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage'>>;
|
|
49
|
+
export interface SigninDbRuntime extends SigninDbTlsRuntime {
|
|
50
|
+
sleep?: (ms: number) => Promise<void>;
|
|
51
|
+
/** How long a new or redeployed database may take to report healthy (default 5 minutes). */
|
|
52
|
+
signinDbTimeoutMs?: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Polls until Coolify reports the app `running:healthy`. A healthy status read before the app was
|
|
56
|
+
* ever seen leaving it could be the container being replaced, so it counts only after two polls in
|
|
57
|
+
* a row. Throws at the deadline; the message names the app and its last status.
|
|
58
|
+
*/
|
|
59
|
+
export declare function waitHealthy(coolify: Pick<CoolifyPort, 'getApplication'>, appId: string, runtime: SigninDbRuntime, label: string, what?: string): Promise<void>;
|
|
60
|
+
/** Makes a Dockerfile app on the first usable server, without a domain: the caller's `create`. */
|
|
61
|
+
export declare function dockerfileAppCreator(coolify: Pick<CoolifyPort, 'listServers' | 'createDockerfileApplication'>, o: {
|
|
62
|
+
projectUuid: string;
|
|
63
|
+
environment: {
|
|
64
|
+
name: string;
|
|
65
|
+
uuid: string;
|
|
66
|
+
};
|
|
67
|
+
name: string;
|
|
68
|
+
description: string;
|
|
69
|
+
}): (dockerfile: string) => Promise<{
|
|
70
|
+
uuid: string;
|
|
71
|
+
}>;
|
|
72
|
+
export interface ProvisionSigninDbInput {
|
|
73
|
+
coolify: Coolify;
|
|
74
|
+
keys: SigninDbKeys | undefined;
|
|
75
|
+
runtime: SigninDbRuntime;
|
|
76
|
+
/** The Coolify name and internal hostname of the database app. */
|
|
77
|
+
name: string;
|
|
78
|
+
internalHost: string;
|
|
79
|
+
/** The volume's name. */
|
|
80
|
+
volumeName: string;
|
|
81
|
+
/** The ref the server certificate is held under. */
|
|
82
|
+
serverCertRef: string;
|
|
83
|
+
/** The company's slug: the nightly dump's folder in the bucket. */
|
|
84
|
+
slug: string;
|
|
85
|
+
/** The host's bucket and key ports. Without them the database is not created. */
|
|
86
|
+
backup: SigninDbBackupPort | undefined;
|
|
87
|
+
/** The company's own backup bucket. */
|
|
88
|
+
backupBucket: string;
|
|
89
|
+
/** The company's recorded ids when the step started. */
|
|
90
|
+
recorded: Readonly<Record<string, string>>;
|
|
91
|
+
label: string;
|
|
92
|
+
create(dockerfile: string): Promise<{
|
|
93
|
+
uuid: string;
|
|
94
|
+
}>;
|
|
95
|
+
environment: {
|
|
96
|
+
id: number;
|
|
97
|
+
};
|
|
98
|
+
record(key: string, id: string): Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Brings the TLS database up and returns ZITADEL's DSN: the CA and server certificate (held in
|
|
102
|
+
* keys), the app with its data volume and no domain, its env (the password minted once and read
|
|
103
|
+
* back after), and a deploy when the app is new, stopped or its env changed. Returns after the
|
|
104
|
+
* database reports healthy. A secret (password, key) goes only to the app's env and to
|
|
105
|
+
* `runtime.onSecret`.
|
|
106
|
+
*/
|
|
107
|
+
export declare function provisionSigninDb(input: ProvisionSigninDbInput): Promise<{
|
|
108
|
+
appId: string;
|
|
109
|
+
dsn: string;
|
|
110
|
+
ca: string;
|
|
111
|
+
password: string;
|
|
112
|
+
}>;
|
|
113
|
+
/** Mounts the CA on ZITADEL's app, once. The CA is public and never changes, so an existing mount is left alone. */
|
|
114
|
+
export declare function ensureSigninDbCaFile(port: Coolify, zitadelAppId: string, caPem: string, label: string): Promise<void>;
|