From 0b45b9fbc5fe6547e5b778a890de7db3fd3c0508 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 13 Sep 2024 12:13:54 +0200 Subject: [PATCH 1/5] tls --- apps/browser-proxy/package.json | 4 +- apps/browser-proxy/src/index.ts | 63 +++++++++------------------ apps/browser-proxy/src/tls.ts | 14 +++++- package-lock.json | 77 ++++++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 45 deletions(-) diff --git a/apps/browser-proxy/package.json b/apps/browser-proxy/package.json index e3f6d0df..223896c0 100644 --- a/apps/browser-proxy/package.json +++ b/apps/browser-proxy/package.json @@ -9,8 +9,10 @@ "dependencies": { "@aws-sdk/client-s3": "^3.645.0", "debug": "^4.3.7", + "expiry-map": "^2.0.0", "findhit-proxywrap": "^0.3.13", - "pg-gateway": "0.3.0-alpha.7", + "p-memoize": "^7.1.1", + "pg-gateway": "^0.3.0-alpha.7", "ws": "^8.18.0" }, "devDependencies": { diff --git a/apps/browser-proxy/src/index.ts b/apps/browser-proxy/src/index.ts index fa215a61..aaa0f3c4 100644 --- a/apps/browser-proxy/src/index.ts +++ b/apps/browser-proxy/src/index.ts @@ -1,43 +1,35 @@ import * as nodeNet from 'node:net' import * as https from 'node:https' -import { PostgresConnection } from 'pg-gateway' +import { BackendError, PostgresConnection } from 'pg-gateway' +import { fromNodeSocket } from 'pg-gateway/node' import { WebSocketServer, type WebSocket } from 'ws' import makeDebug from 'debug' import * as tls from 'node:tls' import { extractDatabaseId, isValidServername } from './servername.ts' -import { getTls } from './tls.ts' +import { getTls, setSecureContext } from './tls.ts' import { createStartupMessage } from './create-message.ts' import { extractIP } from './extract-ip.ts' const debug = makeDebug('browser-proxy') -const tcpConnections = new Map() +const tcpConnections = new Map() const websocketConnections = new Map() -let tlsOptions = await getTls() - -// refresh the TLS certificate every week -setInterval( - async () => { - tlsOptions = await getTls() - httpsServer.setSecureContext(tlsOptions) - }, - 1000 * 60 * 60 * 24 * 7 -) - const httpsServer = https.createServer({ - ...tlsOptions, SNICallback: (servername, callback) => { debug('SNICallback', servername) if (isValidServername(servername)) { debug('SNICallback', 'valid') - callback(null, tls.createSecureContext(tlsOptions)) + callback(null) } else { debug('SNICallback', 'invalid') callback(new Error('invalid SNI')) } }, }) +await setSecureContext(httpsServer) +// reset the secure context every week to pick up any new TLS certificates +setInterval(() => setSecureContext(httpsServer), 1000 * 60 * 60 * 24 * 7) const websocketServer = new WebSocketServer({ server: httpsServer, @@ -70,8 +62,8 @@ websocketServer.on('connection', (socket, request) => { socket.on('message', (data: Buffer) => { debug('websocket message', data.toString('hex')) - const tcpSocket = tcpConnections.get(databaseId) - tcpSocket?.write(data) + const tcpConnection = tcpConnections.get(databaseId) + tcpConnection?.streamWriter?.write(data) }) socket.on('close', () => { @@ -86,50 +78,41 @@ const net = ( const tcpServer = net.createServer() -tcpServer.on('connection', (socket) => { +tcpServer.on('connection', async (socket) => { let databaseId: string | undefined - const connection = new PostgresConnection(socket, { - tls: tlsOptions, + const connection = await fromNodeSocket(socket, { + tls: getTls, onTlsUpgrade(state) { - if (!state.tlsInfo?.sniServerName || !isValidServername(state.tlsInfo.sniServerName)) { - // connection.detach() - connection.sendError({ + if (!state.tlsInfo?.serverName || !isValidServername(state.tlsInfo.serverName)) { + throw BackendError.create({ code: '08006', message: 'invalid SNI', severity: 'FATAL', }) - connection.end() - return } - const _databaseId = extractDatabaseId(state.tlsInfo.sniServerName!) + const _databaseId = extractDatabaseId(state.tlsInfo.serverName!) if (!websocketConnections.has(_databaseId!)) { - // connection.detach() - connection.sendError({ + throw BackendError.create({ code: 'XX000', message: 'the browser is not sharing the database', severity: 'FATAL', }) - connection.end() - return } if (tcpConnections.has(_databaseId)) { - // connection.detach() - connection.sendError({ + throw BackendError.create({ code: '53300', message: 'sorry, too many clients already', severity: 'FATAL', }) - connection.end() - return } // only set the databaseId after we've verified the connection databaseId = _databaseId - tcpConnections.set(databaseId!, connection.socket) + tcpConnections.set(databaseId!, connection) }, serverVersion() { return '16.3' @@ -138,13 +121,11 @@ tcpServer.on('connection', (socket) => { const websocket = websocketConnections.get(databaseId!) if (!websocket) { - connection.sendError({ + throw BackendError.create({ code: 'XX000', message: 'the browser is not sharing the database', severity: 'FATAL', }) - connection.end() - return } const clientIpMessage = createStartupMessage('postgres', 'postgres', { @@ -160,13 +141,11 @@ tcpServer.on('connection', (socket) => { const websocket = websocketConnections.get(databaseId!) if (!websocket) { - connection.sendError({ + throw BackendError.create({ code: 'XX000', message: 'the browser is not sharing the database', severity: 'FATAL', }) - connection.end() - return } debug('tcp message', { message }) diff --git a/apps/browser-proxy/src/tls.ts b/apps/browser-proxy/src/tls.ts index 38a23937..d41656a5 100644 --- a/apps/browser-proxy/src/tls.ts +++ b/apps/browser-proxy/src/tls.ts @@ -1,9 +1,12 @@ import { Buffer } from 'node:buffer' import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3' +import pMemoize from 'p-memoize' +import ExpiryMap from 'expiry-map' +import type { Server } from 'node:https' const s3Client = new S3Client({ forcePathStyle: true }) -export async function getTls() { +async function _getTls() { const cert = await s3Client .send( new GetObjectCommand({ @@ -31,3 +34,12 @@ export async function getTls() { key: Buffer.from(key), } } + +// cache the TLS certificate for 1 week +const cache = new ExpiryMap(1000 * 60 * 60 * 24 * 7) +export const getTls = pMemoize(_getTls, { cache }) + +export async function setSecureContext(httpsServer: Server) { + const tlsOptions = await getTls() + httpsServer.setSecureContext(tlsOptions) +} diff --git a/package-lock.json b/package-lock.json index 26244775..d2b1ddb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,10 @@ "dependencies": { "@aws-sdk/client-s3": "^3.645.0", "debug": "^4.3.7", + "expiry-map": "^2.0.0", "findhit-proxywrap": "^0.3.13", - "pg-gateway": "0.3.0-alpha.7", + "p-memoize": "^7.1.1", + "pg-gateway": "^0.3.0-alpha.7", "ws": "^8.18.0" }, "devDependencies": { @@ -7350,6 +7352,18 @@ "node": ">=6" } }, + "node_modules/expiry-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/expiry-map/-/expiry-map-2.0.0.tgz", + "integrity": "sha512-K1I5wJe2fiqjyUZf/xhxwTpaopw3F+19DsO7Oggl20+3SVTXDIevVRJav0aBMfposQdkl2E4+gnuOKd3j2X0sA==", + "license": "MIT", + "dependencies": { + "map-age-cleaner": "^0.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/exponential-backoff": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", @@ -9598,6 +9612,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/map-age-cleaner": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.2.0.tgz", + "integrity": "sha512-AvxTC6id0fzSf6OyNBTp1syyCuKO7nOJvHgYlhT0Qkkjvk40zZo+av3ayVgXlxnF/DxEzEfY9mMdd7FHsd+wKQ==", + "license": "MIT", + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=7.6" + } + }, "node_modules/markdown-table": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz", @@ -10470,6 +10496,18 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -11286,6 +11324,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11330,6 +11377,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-memoize": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-memoize/-/p-memoize-7.1.1.tgz", + "integrity": "sha512-DZ/bONJILHkQ721hSr/E9wMz5Am/OTJ9P6LhLFo2Tu+jL8044tgc9LwHO8g4PiaYePnlVVRAJcKmgy8J9MVFrA==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0", + "type-fest": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/p-memoize?sponsor=1" + } + }, + "node_modules/p-memoize/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", From e1c9f1f51bf26716019fd170de705a8061ac1d6c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 13 Sep 2024 15:37:50 +0200 Subject: [PATCH 2/5] remove unused import --- apps/browser-proxy/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/browser-proxy/src/index.ts b/apps/browser-proxy/src/index.ts index aaa0f3c4..a8f5fe36 100644 --- a/apps/browser-proxy/src/index.ts +++ b/apps/browser-proxy/src/index.ts @@ -4,7 +4,6 @@ import { BackendError, PostgresConnection } from 'pg-gateway' import { fromNodeSocket } from 'pg-gateway/node' import { WebSocketServer, type WebSocket } from 'ws' import makeDebug from 'debug' -import * as tls from 'node:tls' import { extractDatabaseId, isValidServername } from './servername.ts' import { getTls, setSecureContext } from './tls.ts' import { createStartupMessage } from './create-message.ts' From 3e24ea7cfa11077ef15a1ecbcc43fb0a3d23b8cf Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 13 Sep 2024 18:37:12 +0200 Subject: [PATCH 3/5] fix attributes --- apps/postgres-new/components/live-share-icon.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/postgres-new/components/live-share-icon.tsx b/apps/postgres-new/components/live-share-icon.tsx index 5ca5f908..faa900d7 100644 --- a/apps/postgres-new/components/live-share-icon.tsx +++ b/apps/postgres-new/components/live-share-icon.tsx @@ -11,8 +11,8 @@ export const LiveShareIcon = (props: { size?: number; className?: string }) => ( className={cx('lucide lucide-live-share', props.className)} > From b29c682a9d0c89fe45e67f4ed6b95d6b3cba6d7d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 13 Sep 2024 19:11:52 +0200 Subject: [PATCH 4/5] clean state between clients session --- apps/postgres-new/components/app-provider.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/postgres-new/components/app-provider.tsx b/apps/postgres-new/components/app-provider.tsx index e3fc362d..da2e0ddf 100644 --- a/apps/postgres-new/components/app-provider.tsx +++ b/apps/postgres-new/components/app-provider.tsx @@ -138,7 +138,17 @@ export default function AppProvider({ children }: AppProps) { if (isStartupMessage(message)) { const parameters = parseStartupMessage(message) if ('client_ip' in parameters) { - setConnectedClientIp(parameters.client_ip === '' ? null : parameters.client_ip) + // client disconnected + if (parameters.client_ip === '') { + setConnectedClientIp(null) + // we ensure we're not in a transaction block first + await db.sql`rollback;`.catch() + // we clean the session state, see: https://www.pgbouncer.org/faq.html#how-to-use-prepared-statements-with-session-pooling + // we do this to avoid having old prepared statements in the session + await db.sql`discard all;` + } else { + setConnectedClientIp(parameters.client_ip) + } } return } From 0779d7f3911c15dd474ee2df44b2e99d16050b87 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 17 Sep 2024 14:09:45 +0200 Subject: [PATCH 5/5] bump pg-gateway --- apps/browser-proxy/README.md | 33 +++++++++++++++++++++++++++++++++ apps/browser-proxy/fly.toml | 2 -- apps/browser-proxy/package.json | 2 +- package-lock.json | 8 ++++---- 4 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 apps/browser-proxy/README.md diff --git a/apps/browser-proxy/README.md b/apps/browser-proxy/README.md new file mode 100644 index 00000000..1b6f4e9e --- /dev/null +++ b/apps/browser-proxy/README.md @@ -0,0 +1,33 @@ +# Browser Proxy + +This app is a proxy that sits between the browser and a PostgreSQL client. + +It is using a WebSocket server and a TCP server to make the communication between the PGlite instance in the browser and a standard PostgreSQL client possible. + +## Development + +Copy the `.env.example` file to `.env` and set the correct environment variables. + +Install dependencies: + +```sh +npm install +``` + +Start the proxy in development mode: + +```sh +npm run dev +``` + +## Deployment + +Create a new app on Fly.io, for example `database-build-browser-proxy`. + +Fill the app's secrets with the correct environment variables based on the `.env.example` file. + +Deploy the app: + +```sh +fly deploy --app database-build-browser-proxy +``` \ No newline at end of file diff --git a/apps/browser-proxy/fly.toml b/apps/browser-proxy/fly.toml index 2a8105fc..e23f6c12 100644 --- a/apps/browser-proxy/fly.toml +++ b/apps/browser-proxy/fly.toml @@ -1,5 +1,3 @@ -app = "postgres-new-browser-proxy" - primary_region = 'iad' [[services]] diff --git a/apps/browser-proxy/package.json b/apps/browser-proxy/package.json index 223896c0..d94243da 100644 --- a/apps/browser-proxy/package.json +++ b/apps/browser-proxy/package.json @@ -12,7 +12,7 @@ "expiry-map": "^2.0.0", "findhit-proxywrap": "^0.3.13", "p-memoize": "^7.1.1", - "pg-gateway": "^0.3.0-alpha.7", + "pg-gateway": "^0.3.0-beta.3", "ws": "^8.18.0" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index d2b1ddb0..2e77f52d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "expiry-map": "^2.0.0", "findhit-proxywrap": "^0.3.13", "p-memoize": "^7.1.1", - "pg-gateway": "^0.3.0-alpha.7", + "pg-gateway": "^0.3.0-beta.3", "ws": "^8.18.0" }, "devDependencies": { @@ -41,9 +41,9 @@ } }, "apps/browser-proxy/node_modules/pg-gateway": { - "version": "0.3.0-alpha.7", - "resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-alpha.7.tgz", - "integrity": "sha512-Dpea10K9ecHYNk/ebGciXAEAOrGZVEb1I7ee6QY6+kbMSn+5iOT7yz6z2rwR7nYyA3eNVg3FszczUeakjcbnmg==", + "version": "0.3.0-beta.3", + "resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-beta.3.tgz", + "integrity": "sha512-tzO/TSlFzgu6AJvn4clzZpDfCDyRjG/GnGYtdi1kgN+h+TmaD/1xDfqqVhcknPDx7qQaViq8JW7aQiazn3QaqQ==", "license": "MIT" }, "apps/browser-proxy/node_modules/undici-types": {