diff --git a/.changeset/friendly-adults-shout.md b/.changeset/friendly-adults-shout.md new file mode 100644 index 000000000..ddb5c9638 --- /dev/null +++ b/.changeset/friendly-adults-shout.md @@ -0,0 +1,5 @@ +--- +'@powersync/service-core': minor +--- + +Support IPv6 for JWKS URI. diff --git a/.changeset/old-ducks-suffer.md b/.changeset/old-ducks-suffer.md new file mode 100644 index 000000000..f69b960e2 --- /dev/null +++ b/.changeset/old-ducks-suffer.md @@ -0,0 +1,5 @@ +--- +'@powersync/service-types': patch +--- + +Minor config changes. diff --git a/.changeset/thick-paws-care.md b/.changeset/thick-paws-care.md new file mode 100644 index 000000000..1b886e80a --- /dev/null +++ b/.changeset/thick-paws-care.md @@ -0,0 +1,11 @@ +--- +'@powersync/service-module-postgres': minor +'@powersync/service-module-mongodb': minor +'@powersync/service-core': minor +'@powersync/service-module-mysql': minor +'@powersync/lib-service-postgres': minor +'@powersync/lib-services-framework': minor +'@powersync/lib-service-mongodb': minor +--- + +Allow limiting IP ranges of outgoing connections diff --git a/.changeset/warm-numbers-cheer.md b/.changeset/warm-numbers-cheer.md new file mode 100644 index 000000000..307c2bdae --- /dev/null +++ b/.changeset/warm-numbers-cheer.md @@ -0,0 +1,5 @@ +--- +'@powersync/service-jpgwire': minor +--- + +Allow custom lookup function and tls_servername. Also reduce dependencies. diff --git a/libs/lib-mongodb/src/db/mongo.ts b/libs/lib-mongodb/src/db/mongo.ts index f7e84b47c..6fb853362 100644 --- a/libs/lib-mongodb/src/db/mongo.ts +++ b/libs/lib-mongodb/src/db/mongo.ts @@ -1,6 +1,7 @@ import * as mongo from 'mongodb'; import * as timers from 'timers/promises'; import { BaseMongoConfigDecoded, normalizeMongoConfig } from '../types/types.js'; +import { validateIpHostname } from '@powersync/lib-services-framework'; /** * Time for new connection to timeout. @@ -42,6 +43,8 @@ export function createMongoClient(config: BaseMongoConfigDecoded) { // How long to wait for new primary selection serverSelectionTimeoutMS: 30_000, + lookup: normalized.lookup, + // Avoid too many connections: // 1. It can overwhelm the source database. // 2. Processing too many queries in parallel can cause the process to run out of memory. diff --git a/libs/lib-mongodb/src/types/types.ts b/libs/lib-mongodb/src/types/types.ts index 3875d1081..3647e1d06 100644 --- a/libs/lib-mongodb/src/types/types.ts +++ b/libs/lib-mongodb/src/types/types.ts @@ -1,3 +1,4 @@ +import { LookupOptions, makeHostnameLookupFunction } from '@powersync/lib-services-framework'; import * as t from 'ts-codec'; import * as urijs from 'uri-js'; @@ -8,7 +9,9 @@ export const BaseMongoConfig = t.object({ uri: t.string, database: t.string.optional(), username: t.string.optional(), - password: t.string.optional() + password: t.string.optional(), + + reject_ip_ranges: t.array(t.string).optional() }); export type BaseMongoConfig = t.Encoded; @@ -46,11 +49,18 @@ export function normalizeMongoConfig(options: BaseMongoConfigDecoded) { delete uri.userinfo; + const lookupOptions: LookupOptions = { + reject_ip_ranges: options.reject_ip_ranges ?? [] + }; + const lookup = makeHostnameLookupFunction(uri.host ?? '', lookupOptions); + return { uri: urijs.serialize(uri), database, username, - password + password, + + lookup }; } diff --git a/libs/lib-postgres/package.json b/libs/lib-postgres/package.json index 89a59bbf3..a4f9af3ae 100644 --- a/libs/lib-postgres/package.json +++ b/libs/lib-postgres/package.json @@ -30,7 +30,6 @@ "dependencies": { "@powersync/lib-services-framework": "workspace:*", "@powersync/service-jpgwire": "workspace:*", - "@powersync/service-sync-rules": "workspace:*", "@powersync/service-types": "workspace:*", "p-defer": "^4.0.1", "ts-codec": "^1.3.0", diff --git a/libs/lib-postgres/src/types/types.ts b/libs/lib-postgres/src/types/types.ts index 3d147e10b..05e370dda 100644 --- a/libs/lib-postgres/src/types/types.ts +++ b/libs/lib-postgres/src/types/types.ts @@ -1,24 +1,10 @@ +import { makeHostnameLookupFunction } from '@powersync/lib-services-framework'; +import type * as jpgwire from '@powersync/service-jpgwire'; import * as service_types from '@powersync/service-types'; import * as t from 'ts-codec'; import * as urijs from 'uri-js'; -export interface NormalizedBasePostgresConnectionConfig { - id: string; - tag: string; - - hostname: string; - port: number; - database: string; - - username: string; - password: string; - - sslmode: 'verify-full' | 'verify-ca' | 'disable'; - cacert: string | undefined; - - client_certificate: string | undefined; - client_private_key: string | undefined; -} +export interface NormalizedBasePostgresConnectionConfig extends jpgwire.NormalizedConnectionConfig {} export const POSTGRES_CONNECTION_TYPE = 'postgresql' as const; @@ -43,8 +29,15 @@ export const BasePostgresConnectionConfig = t.object({ client_certificate: t.string.optional(), client_private_key: t.string.optional(), - /** Expose database credentials */ - demo_database: t.boolean.optional(), + /** Specify to use a servername for TLS that is different from hostname. */ + tls_servername: t.string.optional(), + + /** + * Block connections in any of these IP ranges. + * + * Use 'local' to block anything not in public unicast ranges. + */ + reject_ip_ranges: t.array(t.string).optional(), /** * Prefix for the slot name. Defaults to "powersync_" @@ -106,6 +99,9 @@ export function normalizeConnectionConfig(options: BasePostgresConnectionConfigD throw new Error(`database required`); } + const lookupOptions = { reject_ip_ranges: options.reject_ip_ranges ?? [] }; + const lookup = makeHostnameLookupFunction(hostname, lookupOptions); + return { id: options.id ?? 'default', tag: options.tag ?? 'default', @@ -119,6 +115,9 @@ export function normalizeConnectionConfig(options: BasePostgresConnectionConfigD sslmode, cacert, + tls_servername: options.tls_servername ?? undefined, + lookup, + client_certificate: options.client_certificate ?? undefined, client_private_key: options.client_private_key ?? undefined } satisfies NormalizedBasePostgresConnectionConfig; diff --git a/libs/lib-postgres/src/utils/pgwire_utils.ts b/libs/lib-postgres/src/utils/pgwire_utils.ts index 230fef7b8..84c849202 100644 --- a/libs/lib-postgres/src/utils/pgwire_utils.ts +++ b/libs/lib-postgres/src/utils/pgwire_utils.ts @@ -1,7 +1,6 @@ // Adapted from https://github.com/kagis/pgwire/blob/0dc927f9f8990a903f238737326e53ba1c8d094f/mod.js#L2218 import * as pgwire from '@powersync/service-jpgwire'; -import { SqliteJsonValue } from '@powersync/service-sync-rules'; import { logger } from '@powersync/lib-services-framework'; @@ -9,7 +8,7 @@ export function escapeIdentifier(identifier: string) { return `"${identifier.replace(/"/g, '""').replace(/\./g, '"."')}"`; } -export function autoParameter(arg: SqliteJsonValue | boolean): pgwire.StatementParam { +export function autoParameter(arg: any): pgwire.StatementParam { if (arg == null) { return { type: 'varchar', value: null }; } else if (typeof arg == 'string') { diff --git a/libs/lib-postgres/tsconfig.json b/libs/lib-postgres/tsconfig.json index a0ae425c6..c818f7a14 100644 --- a/libs/lib-postgres/tsconfig.json +++ b/libs/lib-postgres/tsconfig.json @@ -8,5 +8,5 @@ "sourceMap": true }, "include": ["src"], - "references": [] + "references": [{ "path": "../../packages/jpgwire" }, { "path": "../lib-services" }] } diff --git a/libs/lib-services/package.json b/libs/lib-services/package.json index aa6eb2069..a54f473cb 100644 --- a/libs/lib-services/package.json +++ b/libs/lib-services/package.json @@ -24,6 +24,7 @@ "better-ajv-errors": "^1.2.0", "bson": "^6.8.0", "dotenv": "^16.4.5", + "ipaddr.js": "^2.1.0", "lodash": "^4.17.21", "ts-codec": "^1.3.0", "uuid": "^9.0.1", diff --git a/libs/lib-services/src/index.ts b/libs/lib-services/src/index.ts index 3efc0de10..54d39d264 100644 --- a/libs/lib-services/src/index.ts +++ b/libs/lib-services/src/index.ts @@ -31,3 +31,6 @@ export * as system from './system/system-index.js'; export * from './utils/utils-index.js'; export * as utils from './utils/utils-index.js'; + +export * from './ip/ip-index.js'; +export * as ip from './ip/ip-index.js'; diff --git a/libs/lib-services/src/ip/ip-index.ts b/libs/lib-services/src/ip/ip-index.ts new file mode 100644 index 000000000..553957f09 --- /dev/null +++ b/libs/lib-services/src/ip/ip-index.ts @@ -0,0 +1 @@ +export * from './lookup.js'; diff --git a/libs/lib-services/src/ip/lookup.ts b/libs/lib-services/src/ip/lookup.ts new file mode 100644 index 000000000..c6d518335 --- /dev/null +++ b/libs/lib-services/src/ip/lookup.ts @@ -0,0 +1,118 @@ +import * as net from 'node:net'; +import * as dns from 'node:dns'; +import * as dnsp from 'node:dns/promises'; +import ip from 'ipaddr.js'; + +export interface LookupOptions { + reject_ip_ranges: string[]; + reject_ipv6?: boolean; +} + +/** + * Generate a custom DNS lookup function, that rejects specific IP ranges. + * + * If hostname is an IP, this synchronously validates it. + * + * @returns a function to use as the `lookup` option in `net.connect`. + */ +export function makeHostnameLookupFunction( + hostname: string, + lookupOptions: LookupOptions +): net.LookupFunction | undefined { + validateIpHostname(hostname, lookupOptions); + return makeLookupFunction(lookupOptions); +} + +/** + * Generate a custom DNS lookup function, that rejects specific IP ranges. + * + * Note: Lookup functions are not used for IPs configured directly. + * For those, validate the IP directly using validateIpHostname(). + * + * @param reject_ip_ranges IPv4 and/or IPv6 subnets to reject, or 'local' to reject any IP that isn't public unicast. + * @returns a function to use as the `lookup` option in `net.connect`, or undefined if no restrictions are applied. + */ +export function makeLookupFunction(lookupOptions: LookupOptions): net.LookupFunction | undefined { + if (lookupOptions.reject_ip_ranges.length == 0 && !lookupOptions.reject_ipv6) { + // No restrictions - use the default behavior + return undefined; + } + return (hostname, options, callback) => { + resolveIp(hostname, lookupOptions) + .then((resolvedAddress) => { + if (options.all) { + callback(null, [resolvedAddress]); + } else { + callback(null, resolvedAddress.address, resolvedAddress.family); + } + }) + .catch((err) => { + callback(err, undefined as any, undefined); + }); + }; +} + +/** + * Validate IPs synchronously. + * + * If the hostname is not an ip, this does nothing. + * + * @param hostname IP or DNS name + * @param options + */ +export function validateIpHostname(hostname: string, options: LookupOptions): void { + const { reject_ip_ranges: reject_ranges } = options; + if (!ip.isValid(hostname)) { + // Treat as a DNS name. + return; + } + + const parsed = ip.parse(hostname); + const rejectLocal = reject_ranges.includes('local'); + const rejectSubnets = reject_ranges.filter((range) => range != 'local'); + + const reject = { blocked: (rejectSubnets ?? []).map((r) => ip.parseCIDR(r)) }; + + if (options.reject_ipv6 && parsed.kind() == 'ipv6') { + throw new Error('IPv6 not supported'); + } + + if (ip.subnetMatch(parsed, reject) == 'blocked') { + // Ranges explicitly blocked, e.g. private IPv6 ranges + throw new Error(`IPs in this range are not supported: ${hostname}`); + } + + if (!rejectLocal) { + return; + } + + if (parsed.kind() == 'ipv4' && parsed.range() == 'unicast') { + // IPv4 - All good + return; + } else if (parsed.kind() == 'ipv6' && parsed.range() == 'unicast') { + // IPv6 - All good + return; + } else { + // Do not connect to any reserved IPs, including loopback and private ranges + throw new Error(`IPs in this range are not supported: ${hostname}`); + } +} + +/** + * Resolve IP, and check that it is in an allowed range. + */ +export async function resolveIp(hostname: string, options: LookupOptions): Promise { + let resolvedAddress: dns.LookupAddress; + if (net.isIPv4(hostname)) { + // Direct ipv4 - all good so far + resolvedAddress = { address: hostname, family: 4 }; + } else if (net.isIPv6(hostname) || net.isIPv4(hostname)) { + // Direct ipv6 - all good so far + resolvedAddress = { address: hostname, family: 6 }; + } else { + // DNS name - resolve it + resolvedAddress = await dnsp.lookup(hostname); + } + validateIpHostname(resolvedAddress.address, options); + return resolvedAddress; +} diff --git a/modules/module-mongodb/src/replication/MongoManager.ts b/modules/module-mongodb/src/replication/MongoManager.ts index 149071f7f..14568f471 100644 --- a/modules/module-mongodb/src/replication/MongoManager.ts +++ b/modules/module-mongodb/src/replication/MongoManager.ts @@ -19,6 +19,8 @@ export class MongoManager { username: options.username, password: options.password }, + + lookup: options.lookup, // Time for connection to timeout connectTimeoutMS: 5_000, // Time for individual requests to timeout diff --git a/modules/module-mongodb/src/types/types.ts b/modules/module-mongodb/src/types/types.ts index 4ee1d18a6..e9ba494ff 100644 --- a/modules/module-mongodb/src/types/types.ts +++ b/modules/module-mongodb/src/types/types.ts @@ -1,5 +1,6 @@ import * as lib_mongo from '@powersync/lib-service-mongodb/types'; import * as service_types from '@powersync/service-types'; +import { LookupFunction } from 'node:net'; import * as t from 'ts-codec'; export enum PostImagesOption { @@ -48,6 +49,8 @@ export interface NormalizedMongoConnectionConfig { username?: string; password?: string; + lookup?: LookupFunction; + postImages: PostImagesOption; } diff --git a/modules/module-mysql/src/types/types.ts b/modules/module-mysql/src/types/types.ts index 43dd17696..1b78d2cb5 100644 --- a/modules/module-mysql/src/types/types.ts +++ b/modules/module-mysql/src/types/types.ts @@ -1,4 +1,7 @@ +import { makeHostnameLookupFunction } from '@powersync/lib-services-framework'; import * as service_types from '@powersync/service-types'; +import { reject } from 'async'; +import { LookupFunction } from 'node:net'; import * as t from 'ts-codec'; import * as urijs from 'uri-js'; @@ -19,6 +22,8 @@ export interface NormalizedMySQLConnectionConfig { cacert?: string; client_certificate?: string; client_private_key?: string; + + lookup?: LookupFunction; } export const MySQLConnectionConfig = service_types.configFile.DataSourceConfig.and( @@ -34,7 +39,9 @@ export const MySQLConnectionConfig = service_types.configFile.DataSourceConfig.a cacert: t.string.optional(), client_certificate: t.string.optional(), - client_private_key: t.string.optional() + client_private_key: t.string.optional(), + + reject_ip_ranges: t.array(t.string).optional() }) ); @@ -90,6 +97,8 @@ export function normalizeConnectionConfig(options: MySQLConnectionConfig): Norma throw new Error(`database required`); } + const lookup = makeHostnameLookupFunction(hostname, { reject_ip_ranges: options.reject_ip_ranges ?? [] }); + return { id: options.id ?? 'default', tag: options.tag ?? 'default', @@ -101,6 +110,8 @@ export function normalizeConnectionConfig(options: MySQLConnectionConfig): Norma username, password, - server_id: options.server_id ?? 1 + server_id: options.server_id ?? 1, + + lookup }; } diff --git a/modules/module-mysql/src/utils/mysql-utils.ts b/modules/module-mysql/src/utils/mysql-utils.ts index f1e831273..61a2e5d36 100644 --- a/modules/module-mysql/src/utils/mysql-utils.ts +++ b/modules/module-mysql/src/utils/mysql-utils.ts @@ -37,6 +37,7 @@ export function createPool(config: types.NormalizedMySQLConnectionConfig, option cert: config.client_certificate }; const hasSSLOptions = Object.values(sslOptions).some((v) => !!v); + // TODO: Use config.lookup for DNS resolution return mysql.createPool({ host: config.hostname, user: config.username, diff --git a/modules/module-postgres/src/replication/PgManager.ts b/modules/module-postgres/src/replication/PgManager.ts index ad1ab899d..586542d52 100644 --- a/modules/module-postgres/src/replication/PgManager.ts +++ b/modules/module-postgres/src/replication/PgManager.ts @@ -50,6 +50,7 @@ export class PgManager { // for the full 6 minutes. // This we are constantly using the connection, we don't need any // custom keepalives. + (connection as any)._socket.setTimeout(SNAPSHOT_SOCKET_TIMEOUT); // Disable statement timeout for snapshot queries. diff --git a/modules/module-postgres/src/utils/pgwire_utils.ts b/modules/module-postgres/src/utils/pgwire_utils.ts index 145786598..3e3a76721 100644 --- a/modules/module-postgres/src/utils/pgwire_utils.ts +++ b/modules/module-postgres/src/utils/pgwire_utils.ts @@ -1,7 +1,7 @@ // Adapted from https://github.com/kagis/pgwire/blob/0dc927f9f8990a903f238737326e53ba1c8d094f/mod.js#L2218 import * as pgwire from '@powersync/service-jpgwire'; -import { SqliteRow, toSyncRulesRow } from '@powersync/service-sync-rules'; +import { DatabaseInputRow, SqliteRow, toSyncRulesRow } from '@powersync/service-sync-rules'; /** * pgwire message -> SQLite row. @@ -10,7 +10,7 @@ import { SqliteRow, toSyncRulesRow } from '@powersync/service-sync-rules'; export function constructAfterRecord(message: pgwire.PgoutputInsert | pgwire.PgoutputUpdate): SqliteRow { const rawData = (message as any).afterRaw; - const record = pgwire.decodeTuple(message.relation, rawData); + const record = decodeTuple(message.relation, rawData); return toSyncRulesRow(record); } @@ -23,6 +23,24 @@ export function constructBeforeRecord(message: pgwire.PgoutputDelete | pgwire.Pg if (rawData == null) { return undefined; } - const record = pgwire.decodeTuple(message.relation, rawData); + const record = decodeTuple(message.relation, rawData); return toSyncRulesRow(record); } + +/** + * We need a high level of control over how values are decoded, to make sure there is no loss + * of precision in the process. + */ +export function decodeTuple(relation: pgwire.PgoutputRelation, tupleRaw: Record): DatabaseInputRow { + let result: Record = {}; + for (let columnName in tupleRaw) { + const rawval = tupleRaw[columnName]; + const typeOid = (relation as any)._tupleDecoder._typeOids.get(columnName); + if (typeof rawval == 'string' && typeOid) { + result[columnName] = pgwire.PgType.decode(rawval, typeOid); + } else { + result[columnName] = rawval; + } + } + return result; +} diff --git a/packages/jpgwire/package.json b/packages/jpgwire/package.json index f063467bf..5b3a4edd3 100644 --- a/packages/jpgwire/package.json +++ b/packages/jpgwire/package.json @@ -19,8 +19,6 @@ }, "dependencies": { "@powersync/service-jsonbig": "workspace:^", - "@powersync/service-types": "workspace:^", - "@powersync/service-sync-rules": "workspace:^", "date-fns": "^4.1.0", "pgwire": "github:kagis/pgwire#f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87" } diff --git a/packages/jpgwire/src/pgwire_node.js b/packages/jpgwire/src/pgwire_node.js index 330d26f7f..e3780a56a 100644 --- a/packages/jpgwire/src/pgwire_node.js +++ b/packages/jpgwire/src/pgwire_node.js @@ -2,27 +2,11 @@ // Based on the version in commit 9532a395d6fa03a59c2231f0ec690806c90bd338 // Modifications marked with `START POWERSYNC ...` -import net from 'net'; -import tls from 'tls'; import { createHash, pbkdf2 as _pbkdf2, randomFillSync } from 'crypto'; -import { once } from 'events'; + import { promisify } from 'util'; import { _net, SaslScramSha256 } from 'pgwire/mod.js'; -import { recordBytesRead } from './metrics.js'; - -// START POWERSYNC -// pgwire doesn't natively support configuring timeouts, but we just hardcode a default. -// Timeout idle connections after 6 minutes (we ping at least every 5 minutes). -const POWERSYNC_SOCKET_DEFAULT_TIMEOUT = 360_000; - -// Timeout for the initial connection (pre-TLS) -// Must be less than the timeout for a HTTP request -const POWERSYNC_SOCKET_CONNECT_TIMEOUT = 20_000; - -// TCP keepalive delay in milliseconds. -// This can help detect dead connections earlier. -const POWERSYNC_SOCKET_KEEPALIVE_INITIAL_DELAY = 40_000; -// END POWERSYNC +import { SocketAdapter } from './socket_adapter.js'; const pbkdf2 = promisify(_pbkdf2); @@ -46,8 +30,8 @@ Object.assign(SaslScramSha256.prototype, { }); Object.assign(_net, { - connect({ hostname, port }) { - return SocketAdapter.connect(hostname, port); + connect(options) { + return SocketAdapter.connect(options); }, reconnectable(err) { return ['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET'].includes(err?.code); @@ -66,125 +50,3 @@ Object.assign(_net, { return sockadapt.close(); } }); - -class SocketAdapter { - static async connect(host, port) { - // START POWERSYNC - // Custom timeout handling - const socket = net.connect({ - host, - port, - - // This closes the connection if no data was sent or received for the given time, - // even if the connection is still actaully alive. - timeout: POWERSYNC_SOCKET_DEFAULT_TIMEOUT, - - // This configures TCP keepalive. - keepAlive: true, - keepAliveInitialDelay: POWERSYNC_SOCKET_KEEPALIVE_INITIAL_DELAY - // Unfortunately it is not possible to set tcp_keepalive_intvl or - // tcp_keepalive_probes here. - }); - try { - const timeout = setTimeout(() => { - socket.destroy(new Error(`Timeout while connecting to ${host}:${port}`)); - }, POWERSYNC_SOCKET_CONNECT_TIMEOUT); - await once(socket, 'connect'); - clearTimeout(timeout); - return new this(socket); - } catch (e) { - socket.destroy(); - throw e; - } - // END POWERSYNC - } - constructor(socket) { - this._readResume = Boolean; // noop - this._writeResume = Boolean; // noop - this._readPauseAsync = (resolve) => (this._readResume = resolve); - this._writePauseAsync = (resolve) => (this._writeResume = resolve); - this._error = null; - /** @type {net.Socket} */ - this._socket = socket; - this._socket.on('readable', (_) => this._readResume()); - this._socket.on('end', (_) => this._readResume()); - // START POWERSYNC - // Custom timeout handling - this._socket.on('timeout', (_) => { - this._socket.destroy(new Error('Socket idle timeout')); - }); - // END POWERSYNC - this._socket.on('error', (error) => { - this._error = error; - this._readResume(); - this._writeResume(); - }); - } - - // START POWERSYNC CUSTOM TIMEOUT - setTimeout(timeout) { - this._socket.setTimeout(timeout); - } - // END POWERSYNC CUSTOM TIMEOUT - - async startTls(host, ca) { - // START POWERSYNC CUSTOM OPTIONS HANDLING - if (!Array.isArray(ca) && typeof ca[0] == 'object') { - throw new Error('Invalid PowerSync TLS options'); - } - const tlsOptions = ca[0]; - - // https://nodejs.org/docs/latest-v14.x/api/tls.html#tls_tls_connect_options_callback - const socket = this._socket; - const tlsSocket = tls.connect({ socket, host, ...tlsOptions }); - // END POWERSYNC CUSTOM OPTIONS HANDLING - await once(tlsSocket, 'secureConnect'); - // TODO check tlsSocket.authorized - - // if secure connection succeeded then we take underlying socket ownership, - // otherwise underlying socket should be closed outside. - tlsSocket.on('close', (_) => socket.destroy()); - return new this.constructor(tlsSocket); - } - /** @param {Uint8Array} out */ - async read(out) { - let buf; - for (;;) { - if (this._error) throw this._error; // TODO callstack - if (this._socket.readableEnded) return null; - // POWERSYNC FIX: Read only as much data as available, instead of reading everything and - // unshifting back onto the socket - const toRead = Math.min(out.length, this._socket.readableLength); - buf = this._socket.read(toRead); - - if (buf?.length) break; - if (!buf) await new Promise(this._readPauseAsync); - } - - if (buf.length > out.length) { - throw new Error('Read more data than expected'); - } - out.set(buf); - // POWERSYNC: Add metrics - recordBytesRead(buf.length); - return buf.length; - } - async write(data) { - // TODO assert Uint8Array - // TODO need to copy data? - if (this._error) throw this._error; // TODO callstack - const p = new Promise(this._writePauseAsync); - this._socket.write(data, this._writeResume); - await p; - if (this._error) throw this._error; // TODO callstack - return data.length; - } - // async closeWrite() { - // if (this._error) throw this._error; // TODO callstack - // const socket_end = promisify(cb => this._socket.end(cb)); - // await socket_end(); - // } - close() { - this._socket.destroy(Error('socket destroyed')); - } -} diff --git a/packages/jpgwire/src/pgwire_types.ts b/packages/jpgwire/src/pgwire_types.ts index a93aeba66..65870f99f 100644 --- a/packages/jpgwire/src/pgwire_types.ts +++ b/packages/jpgwire/src/pgwire_types.ts @@ -1,9 +1,7 @@ // Adapted from https://github.com/kagis/pgwire/blob/0dc927f9f8990a903f238737326e53ba1c8d094f/mod.js#L2218 -import type { PgoutputRelation } from 'pgwire/mod.js'; -import { dateToSqlite, lsnMakeComparable, timestampToSqlite, timestamptzToSqlite } from './util.js'; import { JsonContainer } from '@powersync/service-jsonbig'; -import { DatabaseInputRow } from '@powersync/service-sync-rules'; +import { dateToSqlite, lsnMakeComparable, timestampToSqlite, timestamptzToSqlite } from './util.js'; export class PgType { static decode(text: string, typeOid: number) { @@ -236,21 +234,3 @@ export class PgType { return '\\x' + Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } } - -/** - * We need a high level of control over how values are decoded, to make sure there is no loss - * of precision in the process. - */ -export function decodeTuple(relation: PgoutputRelation, tupleRaw: Record): DatabaseInputRow { - let result: Record = {}; - for (let columnName in tupleRaw) { - const rawval = tupleRaw[columnName]; - const typeOid = (relation as any)._tupleDecoder._typeOids.get(columnName); - if (typeof rawval == 'string' && typeOid) { - result[columnName] = PgType.decode(rawval, typeOid); - } else { - result[columnName] = rawval; - } - } - return result; -} diff --git a/packages/jpgwire/src/socket_adapter.ts b/packages/jpgwire/src/socket_adapter.ts new file mode 100644 index 000000000..5e84450ad --- /dev/null +++ b/packages/jpgwire/src/socket_adapter.ts @@ -0,0 +1,158 @@ +// Fork of pgwire/index.js, customized to handle additional TLS options + +import { once } from 'node:events'; +import net from 'node:net'; +import tls from 'node:tls'; +import { recordBytesRead } from './metrics.js'; + +// pgwire doesn't natively support configuring timeouts, but we just hardcode a default. +// Timeout idle connections after 6 minutes (we ping at least every 5 minutes). +const POWERSYNC_SOCKET_DEFAULT_TIMEOUT = 360_000; + +// Timeout for the initial connection (pre-TLS) +// Must be less than the timeout for a HTTP request +const POWERSYNC_SOCKET_CONNECT_TIMEOUT = 20_000; + +// TCP keepalive delay in milliseconds. +// This can help detect dead connections earlier. +const POWERSYNC_SOCKET_KEEPALIVE_INITIAL_DELAY = 40_000; + +export interface ConnectOptions { + hostname: string; + port: number; + tlsOptions?: tls.ConnectionOptions | false; + lookup?: net.LookupFunction; +} + +export class SocketAdapter { + static async connect(options: ConnectOptions) { + // Custom timeout handling + const socket = net.connect({ + host: options.hostname, + port: options.port, + lookup: options.lookup, + + // This closes the connection if no data was sent or received for the given time, + // even if the connection is still actaully alive. + timeout: POWERSYNC_SOCKET_DEFAULT_TIMEOUT, + + // This configures TCP keepalive. + keepAlive: true, + keepAliveInitialDelay: POWERSYNC_SOCKET_KEEPALIVE_INITIAL_DELAY + // Unfortunately it is not possible to set tcp_keepalive_intvl or + // tcp_keepalive_probes here. + }); + try { + const timeout = setTimeout(() => { + socket.destroy(new Error(`Timeout while connecting to ${options.hostname}:${options.port}`)); + }, POWERSYNC_SOCKET_CONNECT_TIMEOUT); + await once(socket, 'connect'); + clearTimeout(timeout); + return new SocketAdapter(socket, options); + } catch (e) { + socket.destroy(); + throw e; + } + // END POWERSYNC + } + + _socket: net.Socket; + _error: Error | null; + + constructor( + socket: net.Socket, + private options: ConnectOptions + ) { + this._error = null; + this._socket = socket; + this._socket.on('readable', (_) => this._readResume()); + this._socket.on('end', () => this._readResume()); + // Custom timeout handling + this._socket.on('timeout', () => { + this._socket.destroy(new Error('Socket idle timeout')); + }); + this._socket.on('error', (error) => { + this._error = error; + this._readResume(); + this._writeResume(); + }); + } + + _readResume = () => { + // noop + return; + }; + _writeResume = () => { + // noop + return; + }; + + _readPauseAsync = (resolve: () => void) => { + this._readResume = resolve; + }; + _writePauseAsync = (resolve: () => void) => { + this._writeResume = resolve; + }; + + setTimeout(timeout: number) { + this._socket.setTimeout(timeout); + } + + async startTls(host: string, ca: any) { + // START POWERSYNC CUSTOM OPTIONS HANDLING + const tlsOptions = this.options.tlsOptions; + + // https://nodejs.org/docs/latest-v14.x/api/tls.html#tls_tls_connect_options_callback + const socket = this._socket; + const tlsSocket = tls.connect({ socket, host, ...tlsOptions }); + // END POWERSYNC CUSTOM OPTIONS HANDLING + await once(tlsSocket, 'secureConnect'); + // TODO check tlsSocket.authorized + + // if secure connection succeeded then we take underlying socket ownership, + // otherwise underlying socket should be closed outside. + tlsSocket.on('close', (_) => socket.destroy()); + return new SocketAdapter(tlsSocket, this.options); + } + + async read(out: Uint8Array) { + let buf; + for (;;) { + if (this._error) throw this._error; // TODO callstack + if (this._socket.readableEnded) return null; + // POWERSYNC FIX: Read only as much data as available, instead of reading everything and + // unshifting back onto the socket + const toRead = Math.min(out.length, this._socket.readableLength); + buf = this._socket.read(toRead); + + if (buf?.length) break; + if (!buf) await new Promise(this._readPauseAsync); + } + + if (buf.length > out.length) { + throw new Error('Read more data than expected'); + } + out.set(buf); + // POWERSYNC: Add metrics + recordBytesRead(buf.length); + return buf.length; + } + async write(data: Uint8Array) { + // TODO assert Uint8Array + // TODO need to copy data? + if (this._error) throw this._error; // TODO callstack + const p = new Promise(this._writePauseAsync); + this._socket.write(data, this._writeResume); + await p; + if (this._error) throw this._error; // TODO callstack + return data.length; + } + // async closeWrite() { + // if (this._error) throw this._error; // TODO callstack + // const socket_end = promisify(cb => this._socket.end(cb)); + // await socket_end(); + // } + close() { + this._socket.destroy(Error('socket destroyed')); + } +} diff --git a/packages/jpgwire/src/util.ts b/packages/jpgwire/src/util.ts index 309d89717..a2329321f 100644 --- a/packages/jpgwire/src/util.ts +++ b/packages/jpgwire/src/util.ts @@ -1,8 +1,10 @@ -import * as tls from 'tls'; +import * as datefns from 'date-fns'; +import * as net from 'node:net'; +import * as tls from 'node:tls'; import { DEFAULT_CERTS } from './certs.js'; import * as pgwire from './pgwire.js'; import { PgType } from './pgwire_types.js'; -import * as datefns from 'date-fns'; +import { ConnectOptions } from './socket_adapter.js'; // TODO this is duplicated, but maybe that is ok export interface NormalizedConnectionConfig { @@ -19,6 +21,18 @@ export interface NormalizedConnectionConfig { sslmode: 'verify-full' | 'verify-ca' | 'disable'; cacert: string | undefined; + /** + * Specify to use a servername for TLS that is different from hostname. + * + * Only relevant if sslmode = 'verify-full'. + */ + tls_servername: string | undefined; + + /** + * Hostname lookup function. + */ + lookup?: net.LookupFunction; + client_certificate: string | undefined; client_private_key: string | undefined; } @@ -41,7 +55,8 @@ export function clientTlsOptions(options: PgWireConnectionOptions): tls.Connecti return {}; } } -export function tlsOptions(options: PgWireConnectionOptions): false | tls.ConnectionOptions { + +export function makeTlsOptions(options: PgWireConnectionOptions): false | tls.ConnectionOptions { if (options.sslmode == 'disable') { return false; } else if (options.sslmode == 'verify-full') { @@ -53,7 +68,7 @@ export function tlsOptions(options: PgWireConnectionOptions): false | tls.Connec // This may be different from the host we're connecting to if we pre-resolved // the IP. host: options.hostname, - servername: options.hostname, + servername: options.tls_servername ?? options.hostname, ...clientTlsOptions(options) }; } else if (options.sslmode == 'verify-ca') { @@ -74,7 +89,7 @@ export function tlsOptions(options: PgWireConnectionOptions): false | tls.Connec } export async function connectPgWire(config: PgWireConnectionOptions, options?: { type?: 'standard' | 'replication' }) { - let connectionOptions: Mutable = { + let connectionOptions: Mutable = { application_name: 'PowerSync', // tlsOptions below contains the original hostname @@ -90,18 +105,27 @@ export async function connectPgWire(config: PgWireConnectionOptions, options?: { connectionOptions.replication = 'database'; } + let tlsOptions: tls.ConnectionOptions | false = false; + if (config.sslmode != 'disable') { connectionOptions.sslmode = 'require'; - // HACK: Not standard pgwire options - // Just the easiest way to pass on our config to pgwire_node.js - connectionOptions.sslrootcert = tlsOptions(config); + tlsOptions = makeTlsOptions(config); } else { connectionOptions.sslmode = 'disable'; } - const connection = await pgwire.pgconnect(connectionOptions as pgwire.PgConnectOptions); + const connection = pgwire.pgconnection(connectionOptions as pgwire.PgConnectOptions); + + // HACK: Not standard pgwire options + // Just the easiest way to pass on our config to SocketAdapter + const connectOptions = (connection as any)._connectOptions as ConnectOptions; + connectOptions.tlsOptions = tlsOptions; + connectOptions.lookup = config.lookup; + // HACK: Replace row decoding with our own implementation (connection as any)._recvDataRow = _recvDataRow; + + await (connection as any).start(); return connection; } @@ -147,7 +171,7 @@ export function connectPgWirePool(config: PgWireConnectionOptions, options?: PgP const idleTimeout = options?.idleTimeout; const maxSize = options?.maxSize ?? 5; - let connectionOptions: Mutable = { + let connectionOptions: Mutable = { application_name: 'PowerSync', // tlsOptions below contains the original hostname @@ -162,12 +186,11 @@ export function connectPgWirePool(config: PgWireConnectionOptions, options?: PgP _poolIdleTimeout: idleTimeout }; + let tlsOptions: tls.ConnectionOptions | false = false; if (config.sslmode != 'disable') { connectionOptions.sslmode = 'require'; - // HACK: Not standard pgwire options - // Just the easiest way to pass on our config to pgwire_node.js - connectionOptions.sslrootcert = tlsOptions(config); + tlsOptions = makeTlsOptions(config); } else { connectionOptions.sslmode = 'disable'; } @@ -176,6 +199,11 @@ export function connectPgWirePool(config: PgWireConnectionOptions, options?: PgP const originalGetConnection = (pool as any)._getConnection; (pool as any)._getConnection = function (this: any) { const con = originalGetConnection.call(this); + + const connectOptions = (con as any)._connectOptions as ConnectOptions; + connectOptions.tlsOptions = tlsOptions; + connectOptions.lookup = config.lookup; + // HACK: Replace row decoding with our own implementation (con as any)._recvDataRow = _recvDataRow; return con; @@ -183,15 +211,6 @@ export function connectPgWirePool(config: PgWireConnectionOptions, options?: PgP return pool; } -/** - * Hack: sslrootcert is passed through as-is to pgwire_node. - * - * We use that to pass in custom TLS options, without having to modify pgwire itself. - */ -export interface ExtendedPgwireOptions extends pgwire.PgConnectKnownOptions { - sslrootcert?: false | tls.ConnectionOptions; -} - export function lsnMakeComparable(text: string) { const [h, l] = text.split('/'); return h.padStart(8, '0') + '/' + l.padStart(8, '0'); diff --git a/packages/service-core/src/auth/RemoteJWKSCollector.ts b/packages/service-core/src/auth/RemoteJWKSCollector.ts index 59f398380..bcae78749 100644 --- a/packages/service-core/src/auth/RemoteJWKSCollector.ts +++ b/packages/service-core/src/auth/RemoteJWKSCollector.ts @@ -1,19 +1,14 @@ -import * as https from 'https'; import * as http from 'http'; -import * as dns from 'dns/promises'; -import ip from 'ipaddr.js'; +import * as https from 'https'; import * as jose from 'jose'; -import * as net from 'net'; import fetch from 'node-fetch'; -import { KeySpec } from './KeySpec.js'; +import { LookupOptions, makeHostnameLookupFunction } from '@powersync/lib-services-framework'; import { KeyCollector, KeyResult } from './KeyCollector.js'; +import { KeySpec } from './KeySpec.js'; export type RemoteJWKSCollectorOptions = { - /** - * Blocks IP Ranges from the BLOCKED_IP_RANGES array - */ - block_local_ip?: boolean; + lookupOptions?: LookupOptions; }; /** @@ -21,6 +16,7 @@ export type RemoteJWKSCollectorOptions = { */ export class RemoteJWKSCollector implements KeyCollector { private url: URL; + private agent: http.Agent; constructor( url: string, @@ -37,6 +33,8 @@ export class RemoteJWKSCollector implements KeyCollector { if (this.url.protocol != 'https:' && this.url.protocol != 'http:') { throw new Error(`Only http(s) is supported for jwks_uri, got: ${url}`); } + + this.agent = this.resolveAgent(); } async getKeys(): Promise { @@ -51,7 +49,7 @@ export class RemoteJWKSCollector implements KeyCollector { Accept: 'application/json' }, signal: abortController.signal, - agent: await this.resolveAgent() + agent: this.agent }); if (!res.ok) { @@ -97,29 +95,14 @@ export class RemoteJWKSCollector implements KeyCollector { } /** - * Resolve IP, and check that it is in an allowed range. + * Agent that uses a custom lookup function. */ - async resolveAgent(): Promise { - const hostname = this.url.hostname; - let resolved_ip: string; - if (net.isIPv6(hostname)) { - throw new Error('IPv6 not supported yet'); - } else if (net.isIPv4(hostname)) { - // All good - resolved_ip = hostname; - } else { - resolved_ip = (await dns.resolve4(hostname))[0]; - } - - const parsed = ip.parse(resolved_ip); - if (parsed.kind() != 'ipv4' || (this.options?.block_local_ip && parsed.range() !== 'unicast')) { - // Do not connect to any reserved IPs, including loopback and private ranges - throw new Error(`IPs in this range are not supported: ${resolved_ip}`); - } + resolveAgent(): http.Agent | https.Agent { + const lookupOptions = this.options?.lookupOptions ?? { reject_ip_ranges: [] }; + const lookup = makeHostnameLookupFunction(this.url.hostname, lookupOptions); - const options = { - // This is the host that the agent connects to - host: resolved_ip + const options: http.AgentOptions = { + lookup }; switch (this.url.protocol) { diff --git a/packages/service-core/src/util/config/compound-config-collector.ts b/packages/service-core/src/util/config/compound-config-collector.ts index 8f5d304b0..48f32e1da 100644 --- a/packages/service-core/src/util/config/compound-config-collector.ts +++ b/packages/service-core/src/util/config/compound-config-collector.ts @@ -1,4 +1,4 @@ -import { logger } from '@powersync/lib-services-framework'; +import { logger, LookupOptions } from '@powersync/lib-services-framework'; import { configFile } from '@powersync/service-types'; import * as auth from '../../auth/auth-index.js'; import { ConfigCollector } from './collectors/config-collector.js'; @@ -91,12 +91,23 @@ export class CompoundConfigCollector { jwks_uris = [jwks_uris]; } + let jwksLookup: LookupOptions = { + reject_ip_ranges: [] + }; + + if (baseConfig.client_auth?.jwks_reject_ip_ranges != null) { + jwksLookup = { + reject_ip_ranges: baseConfig.client_auth?.jwks_reject_ip_ranges + }; + } + if (baseConfig.client_auth?.block_local_jwks) { + // Deprecated - recommend method is to use jwks_reject_ip_ranges + jwksLookup.reject_ip_ranges.push('local'); + jwksLookup.reject_ipv6 = true; + } + for (let uri of jwks_uris) { - collectors.add( - new auth.CachedKeyCollector( - new auth.RemoteJWKSCollector(uri, { block_local_ip: !!baseConfig.client_auth?.block_local_jwks }) - ) - ); + collectors.add(new auth.CachedKeyCollector(new auth.RemoteJWKSCollector(uri, { lookupOptions: jwksLookup }))); } const baseDevKey = (baseConfig.client_auth?.jwks?.keys ?? []).find((key) => key.kid == POWERSYNC_DEV_KID); @@ -121,9 +132,6 @@ export class CompoundConfigCollector { api_tokens: baseConfig.api?.tokens ?? [], dev: { demo_auth: baseConfig.dev?.demo_auth ?? false, - demo_client: baseConfig.dev?.demo_client ?? false, - demo_password: baseConfig.dev?.demo_password, - crud_api: baseConfig.dev?.crud_api ?? false, dev_key: devKey }, port: baseConfig.port ?? 8080, diff --git a/packages/service-core/src/util/config/types.ts b/packages/service-core/src/util/config/types.ts index f56dce957..d1d208bd8 100644 --- a/packages/service-core/src/util/config/types.ts +++ b/packages/service-core/src/util/config/types.ts @@ -34,9 +34,6 @@ export type ResolvedPowerSyncConfig = { storage: configFile.GenericStorageConfig; dev: { demo_auth: boolean; - demo_password?: string; - crud_api: boolean; - demo_client: boolean; /** * Only present when demo_auth == true */ diff --git a/packages/service-core/test/src/auth.test.ts b/packages/service-core/test/src/auth.test.ts index 59ee90bf0..8f45d428d 100644 --- a/packages/service-core/test/src/auth.test.ts +++ b/packages/service-core/test/src/auth.test.ts @@ -284,11 +284,23 @@ describe('JWT Auth', () => { expect(errors).toEqual([]); expect(keys.length).toBeGreaterThanOrEqual(1); - // The localhost hostname fails to resolve correctly on MacOS https://github.com/nodejs/help/issues/2163 - const invalid = new RemoteJWKSCollector('https://127.0.0.1/.well-known/jwks.json', { - block_local_ip: true + // Domain names are resolved when retrieving keys + const invalid = new RemoteJWKSCollector('https://localhost/.well-known/jwks.json', { + lookupOptions: { + reject_ip_ranges: ['local'] + } }); expect(invalid.getKeys()).rejects.toThrow('IPs in this range are not supported'); + + // IPS throw an error immediately + expect( + () => + new RemoteJWKSCollector('https://127.0.0.1/.well-known/jwks.json', { + lookupOptions: { + reject_ip_ranges: ['local'] + } + }) + ).toThrowError('IPs in this range are not supported'); }); test('http not blocking local IPs', async () => { @@ -301,10 +313,13 @@ describe('JWT Auth', () => { expect(errors).toEqual([]); expect(keys.length).toBeGreaterThanOrEqual(1); - // The localhost hostname fails to resolve correctly on MacOS https://github.com/nodejs/help/issues/2163 const invalid = new RemoteJWKSCollector('https://127.0.0.1/.well-known/jwks.json'); // Should try and fetch - expect(invalid.getKeys()).rejects.toThrow('ECONNREFUSED'); + expect(invalid.getKeys()).rejects.toThrow(); + + const invalid2 = new RemoteJWKSCollector('https://localhost/.well-known/jwks.json'); + // Should try and fetch + expect(invalid2.getKeys()).rejects.toThrow(); }); test('caching', async () => { diff --git a/packages/types/src/config/PowerSyncConfig.ts b/packages/types/src/config/PowerSyncConfig.ts index bd91b8792..0bafebe65 100644 --- a/packages/types/src/config/PowerSyncConfig.ts +++ b/packages/types/src/config/PowerSyncConfig.ts @@ -128,8 +128,11 @@ export const powerSyncConfig = t.object({ dev: t .object({ demo_auth: t.boolean.optional(), + /** @deprecated */ demo_password: t.string.optional(), + /** @deprecated */ crud_api: t.boolean.optional(), + /** @deprecated */ demo_client: t.boolean.optional() }) .optional(), @@ -138,6 +141,7 @@ export const powerSyncConfig = t.object({ .object({ jwks_uri: t.string.or(t.array(t.string)).optional(), block_local_jwks: t.boolean.optional(), + jwks_reject_ip_ranges: t.array(t.string).optional(), jwks: strictJwks.optional(), supabase: t.boolean.optional(), supabase_jwt_secret: t.string.optional(), diff --git a/packages/types/src/config/normalize.ts b/packages/types/src/config/normalize.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f57c90eb3..03d0aaaf1 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,5 +1,4 @@ export * as configFile from './config/PowerSyncConfig.js'; export * from './definitions.js'; -export * from './config/normalize.js'; export * as internal_routes from './routes.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63a93834a..f914cf675 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,9 +92,6 @@ importers: '@powersync/service-jpgwire': specifier: workspace:* version: link:../../packages/jpgwire - '@powersync/service-sync-rules': - specifier: workspace:* - version: link:../../packages/sync-rules '@powersync/service-types': specifier: workspace:* version: link:../../packages/types @@ -129,6 +126,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 + ipaddr.js: + specifier: ^2.1.0 + version: 2.2.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -405,12 +405,6 @@ importers: '@powersync/service-jsonbig': specifier: workspace:^ version: link:../jsonbig - '@powersync/service-sync-rules': - specifier: workspace:^ - version: link:../sync-rules - '@powersync/service-types': - specifier: workspace:^ - version: link:../types date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -4899,7 +4893,7 @@ snapshots: '@opentelemetry/semantic-conventions': 1.25.1 '@prisma/instrumentation': 5.16.1 '@sentry/core': 8.17.0 - '@sentry/opentelemetry': 8.17.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1) + '@sentry/opentelemetry': 8.17.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.6.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1) '@sentry/types': 8.17.0 '@sentry/utils': 8.17.0 optionalDependencies: @@ -4907,7 +4901,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@sentry/opentelemetry@8.17.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1)': + '@sentry/opentelemetry@8.17.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.6.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0)