Skip to content

Commit ceb38c4

Browse files
committed
Merge development: verify CDC DB TLS identity, restore replication (deploy gen 250)
Mirrors the backend TLS identity fix in the CDC worker: cdcDb verified the cert against localhost, so the replication slot was never created and CDC stayed degraded(replication_stopped). Pins checkServerIdentity to the dialed host and verifies the replication stream too.
2 parents e5e466d + a7edda8 commit ceb38c4

2 files changed

Lines changed: 53 additions & 11 deletions

File tree

cdc/src/lib/db.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type DrizzleConfig } from 'drizzle-orm';
22
import { drizzle } from 'drizzle-orm/node-postgres';
3+
import { type PeerCertificate, checkServerIdentity as tlsCheckServerIdentity } from 'node:tls';
34
import { env } from '../env';
45

56
/**
@@ -15,7 +16,7 @@ const dbConfig: DrizzleConfig = {
1516
// misconfiguration we fail fast on rather than silently downgrading security.
1617
// The secret is base64-encoded (the PEM is multi-line and would break the
1718
// line-based `.env.runtime` delivery), so decode it back to PEM here.
18-
const sslConfig =
19+
const sslCa =
1920
env.NODE_ENV === 'production'
2021
? (() => {
2122
if (!env.DATABASE_SSL_CA) {
@@ -25,23 +26,55 @@ const sslConfig =
2526
"CLI → 'Apply infra change', or check the database-ssl-ca runtime secret.",
2627
);
2728
}
28-
return { ca: Buffer.from(env.DATABASE_SSL_CA, 'base64').toString('utf-8'), rejectUnauthorized: true };
29+
return Buffer.from(env.DATABASE_SSL_CA, 'base64').toString('utf-8');
2930
})()
3031
: undefined;
3132

3233
// Scaleway-built connection strings pin `sslmode=require&uselibpqcompat=true`,
33-
// which pg would let override the verified `ssl` config above. Strip those
34-
// params so our CA-pinned config wins.
35-
const connectionString = (() => {
34+
// which pg would let override the verified `ssl` config below (downgrading to
35+
// no certificate verification). Strip those params so our CA-pinned config wins.
36+
export const stripSslParams = (url: string): string => {
3637
try {
37-
const parsed = new URL(env.DATABASE_CDC_URL);
38+
const parsed = new URL(url);
3839
parsed.searchParams.delete('sslmode');
3940
parsed.searchParams.delete('uselibpqcompat');
4041
return parsed.toString();
4142
} catch {
42-
return env.DATABASE_CDC_URL;
43+
return url;
4344
}
44-
})();
45+
};
46+
47+
/** Parse the host (private IP or DNS name) from a postgres connection string. */
48+
const hostOf = (connectionString: string): string | undefined => {
49+
try {
50+
return new URL(stripSslParams(connectionString)).hostname || undefined;
51+
} catch {
52+
return undefined;
53+
}
54+
};
55+
56+
/**
57+
* Build the verified-TLS `ssl` option for one connection. We pin the CA and keep
58+
* `rejectUnauthorized` so the chain is fully verified. The Scaleway RDB cert
59+
* carries proper SANs (e.g. `DNS:10.0.0.2, IP Address:10.0.0.2`), so the only
60+
* problem is WHICH host the identity check runs against: node-postgres does not
61+
* thread the connection host into the TLS layer, so the check defaults to
62+
* `localhost` and fails (ERR_TLS_CERT_ALTNAME_INVALID) even though the cert
63+
* legitimately covers the host we dialed. Pin the identity check to that actual
64+
* host so the cert's real SANs are honored (chain still verified against the CA).
65+
* Returns `undefined` outside production, where TLS is not required.
66+
*/
67+
export const buildVerifiedSsl = (connectionString: string) => {
68+
if (!sslCa) return undefined;
69+
const host = hostOf(connectionString);
70+
return {
71+
ca: sslCa,
72+
rejectUnauthorized: true,
73+
checkServerIdentity: host ? (_passedHost: string, cert: PeerCertificate) => tlsCheckServerIdentity(host, cert) : undefined,
74+
};
75+
};
76+
77+
const connectionString = stripSslParams(env.DATABASE_CDC_URL);
4578

4679
/**
4780
* CDC database client.
@@ -53,6 +86,6 @@ const connectionString = (() => {
5386
* privileges.
5487
*/
5588
export const cdcDb = drizzle({
56-
connection: { connectionString, connectionTimeoutMillis: 10_000, max: 20, ssl: sslConfig },
89+
connection: { connectionString, connectionTimeoutMillis: 10_000, max: 20, ssl: buildVerifiedSsl(env.DATABASE_CDC_URL) },
5790
...dbConfig,
5891
});

cdc/src/pipeline/replication.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { sql } from 'drizzle-orm';
33
import { appConfig } from 'shared';
44

55
import { CDC_SLOT_NAME, RESOURCE_LIMITS } from '../constants';
6-
import { cdcDb } from '../lib/db';
6+
import { buildVerifiedSsl, cdcDb, stripSslParams } from '../lib/db';
77
import { env } from '../env';
88
import { logError, logEvent } from '../lib/pino';
99

@@ -21,9 +21,13 @@ const { reconnection, slotTakeover } = RESOURCE_LIMITS;
2121

2222
/**
2323
* Build the replication connection URL from the CDC database URL.
24+
*
25+
* Strips the `sslmode=require&uselibpqcompat=true` params so the explicit,
26+
* CA-verified `ssl` config (see {@link createReplicationService}) is used
27+
* instead of pg's unverified libpq-compat downgrade.
2428
*/
2529
function buildReplicationUrl(): URL {
26-
const replicationUrl = new URL(env.DATABASE_CDC_URL);
30+
const replicationUrl = new URL(stripSslParams(env.DATABASE_CDC_URL));
2731
if (!replicationUrl.searchParams.has('replication')) {
2832
replicationUrl.searchParams.set('replication', 'database');
2933
}
@@ -39,6 +43,11 @@ export function createReplicationService(): LogicalReplicationService {
3943
{
4044
connectionString: connectionUrl.toString(),
4145
application_name: `${appConfig.slug}-cdc-worker`,
46+
// Verified TLS, consistent with cdcDb: the replication stream carries full
47+
// row data and must not silently downgrade. buildVerifiedSsl pins the cert
48+
// identity check to the dialed host so the Scaleway RDB cert's SANs are
49+
// honored (returns undefined outside production, where TLS is not required).
50+
ssl: buildVerifiedSsl(env.DATABASE_CDC_URL),
4251
},
4352
{
4453
acknowledge: { auto: false, timeoutSeconds: 0 },

0 commit comments

Comments
 (0)