Skip to content

Commit

Permalink
feat: support Cloud SQL CAS-based instances (#390)
Browse files Browse the repository at this point in the history
The CAS instances will have a different way to verify the server identity.
They will use the dnsName of the Cloud SQL instance as a SAN.
  • Loading branch information
jackwotherspoon authored Sep 30, 2024
1 parent 4e3f31b commit 5d2c02f
Show file tree
Hide file tree
Showing 10 changed files with 182 additions and 12 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ jobs:
POSTGRES_IAM_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE
POSTGRES_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_PASS
POSTGRES_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_DB
POSTGRES_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_CONNECTION_NAME
POSTGRES_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_PASS
SQLSERVER_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_CONNECTION_NAME
SQLSERVER_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_USER
SQLSERVER_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_PASS
Expand Down Expand Up @@ -212,6 +214,8 @@ jobs:
POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_IAM_USER }}"
POSTGRES_PASS: "${{ steps.secrets.outputs.POSTGRES_PASS }}"
POSTGRES_DB: "${{ steps.secrets.outputs.POSTGRES_DB }}"
POSTGRES_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CAS_CONNECTION_NAME }}"
POSTGRES_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CAS_PASS }}"
SQLSERVER_CONNECTION_NAME: "${{ steps.secrets.outputs.SQLSERVER_CONNECTION_NAME }}"
SQLSERVER_USER: "${{ steps.secrets.outputs.SQLSERVER_USER }}"
SQLSERVER_PASS: "${{ steps.secrets.outputs.SQLSERVER_PASS }}"
Expand All @@ -234,6 +238,8 @@ jobs:
POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_IAM_USER }}"
POSTGRES_PASS: "${{ steps.secrets.outputs.POSTGRES_PASS }}"
POSTGRES_DB: "${{ steps.secrets.outputs.POSTGRES_DB }}"
POSTGRES_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CAS_CONNECTION_NAME }}"
POSTGRES_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CAS_PASS }}"
SQLSERVER_CONNECTION_NAME: "${{ steps.secrets.outputs.SQLSERVER_CONNECTION_NAME }}"
SQLSERVER_USER: "${{ steps.secrets.outputs.SQLSERVER_USER }}"
SQLSERVER_PASS: "${{ steps.secrets.outputs.SQLSERVER_PASS }}"
Expand Down
4 changes: 4 additions & 0 deletions src/cloud-sql-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export class CloudSQLInstance {
public port = 3307;
public privateKey?: string;
public serverCaCert?: SslCert;
public serverCaMode = '';
public dnsName = '';

constructor({
ipType,
Expand Down Expand Up @@ -193,6 +195,8 @@ export class CloudSQLInstance {
const host = selectIpAddress(metadata.ipAddresses, this.ipType);
const privateKey = rsaKeys.privateKey;
const serverCaCert = metadata.serverCaCert;
this.serverCaMode = metadata.serverCaMode;
this.dnsName = metadata.dnsName;

const currentValues = {
ephemeralCert: this.ephemeralCert,
Expand Down
4 changes: 4 additions & 0 deletions src/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export class Connector {
port,
privateKey,
serverCaCert,
serverCaMode,
dnsName,
} = cloudSqlInstance;

if (
Expand All @@ -223,6 +225,8 @@ export class Connector {
port,
privateKey,
serverCaCert,
serverCaMode,
dnsName,
});
tlsSocket.once('error', async () => {
await cloudSqlInstance.forceRefresh();
Expand Down
19 changes: 17 additions & 2 deletions src/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,19 @@ interface SocketOptions {
instanceInfo: InstanceConnectionInfo;
privateKey: string;
serverCaCert: SslCert;
serverCaMode: string;
dnsName: string;
}

export function validateCertificate(instanceInfo: InstanceConnectionInfo) {
export function validateCertificate(
instanceInfo: InstanceConnectionInfo,
serverCaMode: string,
dnsName: string
) {
return (hostname: string, cert: tls.PeerCertificate): Error | undefined => {
if (serverCaMode === 'GOOGLE_MANAGED_CAS_CA') {
return tls.checkServerIdentity(dnsName, cert);
}
if (!cert || !cert.subject) {
return new CloudSQLConnectorError({
message: 'No certificate to verify',
Expand All @@ -54,6 +63,8 @@ export function getSocket({
instanceInfo,
privateKey,
serverCaCert,
serverCaMode,
dnsName,
}: SocketOptions): tls.TLSSocket {
const socketOpts = {
host,
Expand All @@ -64,7 +75,11 @@ export function getSocket({
key: privateKey,
minVersion: 'TLSv1.3',
}),
checkServerIdentity: validateCertificate(instanceInfo),
checkServerIdentity: validateCertificate(
instanceInfo,
serverCaMode,
dnsName
),
};
const tlsSocket = tls.connect(socketOpts);
tlsSocket.setKeepAlive(true, DEFAULT_KEEP_ALIVE_DELAY_MS);
Expand Down
4 changes: 4 additions & 0 deletions src/sqladmin-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {AuthTypes} from './auth-types';
export interface InstanceMetadata {
ipAddresses: IpAddresses;
serverCaCert: SslCert;
serverCaMode: string;
dnsName: string;
}

interface RequestBody {
Expand Down Expand Up @@ -216,6 +218,8 @@ export class SQLAdminFetcher {
cert: serverCaCert.cert,
expirationTime: serverCaCert.expirationTime,
},
serverCaMode: res.data.serverCaMode || '',
dnsName: res.data.dnsName || '',
};
}

Expand Down
28 changes: 28 additions & 0 deletions system-test/pg-connect.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,31 @@ t.test('open IAM connection and retrieves standard pg tables', async t => {
await client.end();
connector.close();
});

t.test(
'open connection to CAS-based CA instance and retrieves standard pg tables',
async t => {
const connector = new Connector();
const clientOpts = await connector.getOptions({
instanceConnectionName: process.env.POSTGRES_CAS_CONNECTION_NAME,
ipType: 'PUBLIC',
authType: 'PASSWORD',
});
const client = new Client({
...clientOpts,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_CAS_PASS,
database: process.env.POSTGRES_DB,
});
client.connect();

const {
rows: [result],
} = await client.query('SELECT NOW();');
const returnedDate = result['now'];
t.ok(returnedDate.getTime(), 'should have valid returned date object');

await client.end();
connector.close();
}
);
28 changes: 28 additions & 0 deletions system-test/pg-connect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,31 @@ t.test('open IAM connection and retrieves standard pg tables', async t => {
await client.end();
connector.close();
});

t.test(
'open connection to CAS-based CA instance and retrieves standard pg tables',
async t => {
const connector = new Connector();
const clientOpts = await connector.getOptions({
instanceConnectionName: process.env.POSTGRES_CAS_CONNECTION_NAME,
ipType: 'PUBLIC',
authType: 'PASSWORD',
});
const client = new Client({
...clientOpts,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_CAS_PASS,
database: process.env.POSTGRES_DB,
});
client.connect();

const {
rows: [result],
} = await client.query('SELECT NOW();');
const returnedDate = result['now'];
t.ok(returnedDate.getTime(), 'should have valid returned date object');

await client.end();
connector.close();
}
);
26 changes: 26 additions & 0 deletions system-test/pg-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,29 @@ t.test('open IAM connection and retrieves standard pg tables', async t => {
await client.end();
connector.close();
});

t.test(
'open connection to CAS-based CA instance and retrieves standard pg tables',
async t => {
const connector = new Connector();
const clientOpts = await connector.getOptions({
instanceConnectionName: String(process.env.POSTGRES_CAS_CONNECTION_NAME),
});
const client = new Client({
...clientOpts,
user: String(process.env.POSTGRES_USER),
password: String(process.env.POSTGRES_CAS_PASS),
database: String(process.env.POSTGRES_DB),
});
client.connect();

const {
rows: [result],
} = await client.query('SELECT NOW();');
const returnedDate = result['now'];
t.ok(returnedDate.getTime(), 'should have valid returned date object');

await client.end();
connector.close();
}
);
72 changes: 62 additions & 10 deletions test/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ t.test('getSocket', async t => {
cert: CA_CERT,
expirationTime: '2033-01-06T10:00:00.232Z',
},
serverCaMode: 'GOOGLE_MANAGED_INTERNAL_CA',
dnsName: 'abcde.12345.us-central1.sql.goog',
});

socket.on('secureConnect', () => {
Expand All @@ -61,11 +63,15 @@ t.test('getSocket', async t => {

t.test('validateCertificate no cert', async t => {
t.match(
validateCertificate({
projectId: 'my-project',
regionId: 'region-id',
instanceId: 'my-instance',
})('hostname', {} as tls.PeerCertificate),
validateCertificate(
{
projectId: 'my-project',
regionId: 'region-id',
instanceId: 'my-instance',
},
'GOOGLE_MANAGED_INTERNAL_CA',
'abcde.12345.us-central1.sql.goog'
)('hostname', {} as tls.PeerCertificate),
{code: 'ENOSQLADMINVERIFYCERT'},
'should return a missing cert to verify error'
);
Expand All @@ -78,11 +84,15 @@ t.test('validateCertificate mismatch', async t => {
},
} as tls.PeerCertificate;
t.match(
validateCertificate({
projectId: 'my-project',
regionId: 'region-id',
instanceId: 'my-instance',
})('hostname', cert),
validateCertificate(
{
projectId: 'my-project',
regionId: 'region-id',
instanceId: 'my-instance',
},
'GOOGLE_MANAGED_INTERNAL_CA',
'abcde.12345.us-central1.sql.goog'
)('hostname', cert),
{
message:
'Certificate had CN other-project:other-instance, expected my-project:my-instance',
Expand All @@ -91,3 +101,45 @@ t.test('validateCertificate mismatch', async t => {
'should return a missing cert to verify error'
);
});

t.test('validateCertificate mismatch CAS CA', async t => {
const cert = {
subjectaltname: 'DNS:abcde.12345.us-central1.sql.goog',
} as tls.PeerCertificate;
t.match(
validateCertificate(
{
projectId: 'my-project',
regionId: 'region-id',
instanceId: 'my-instance',
},
'GOOGLE_MANAGED_CAS_CA',
'bad.dns.us-central1.sql.goog'
)('hostname', cert),
{
message:
"Hostname/IP does not match certificate's altnames: Host: bad.dns.us-central1.sql.goog. is not in the cert's altnames: DNS:abcde.12345.us-central1.sql.goog",
code: 'ERR_TLS_CERT_ALTNAME_INVALID',
},
'should return an invalid altname error'
);
});

t.test('validateCertificate valid CAS CA', async t => {
const cert = {
subjectaltname: 'DNS:abcde.12345.us-central1.sql.goog',
} as tls.PeerCertificate;
t.match(
validateCertificate(
{
projectId: 'my-project',
regionId: 'region-id',
instanceId: 'my-instance',
},
'GOOGLE_MANAGED_CAS_CA',
'abcde.12345.us-central1.sql.goog'
)('hostname', cert),
undefined,
'DNS name matches SAN in cert'
);
});
3 changes: 3 additions & 0 deletions test/sqladmin-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const mockSQLAdminGetInstanceMetadata = (
pscEnabled: true,
region: regionId,
serverCaCert: serverCaCertResponse(instanceId),
serverCaMode: 'GOOGLE_MANAGED_INTERNAL_CA',
...overrides,
},
});
Expand Down Expand Up @@ -189,6 +190,8 @@ t.test('getInstanceMetadata', async t => {
cert: '-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----',
expirationTime: '2033-01-06T10:00:00.232Z',
},
serverCaMode: 'GOOGLE_MANAGED_INTERNAL_CA',
dnsName: 'abcde.12345.us-central1.sql.goog',
},
'should return expected instance metadata object'
);
Expand Down

0 comments on commit 5d2c02f

Please sign in to comment.