Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/friendly-adults-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/service-core': minor
---

Support IPv6 for JWKS URI.
5 changes: 5 additions & 0 deletions .changeset/old-ducks-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/service-types': patch
---

Minor config changes.
11 changes: 11 additions & 0 deletions .changeset/thick-paws-care.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/warm-numbers-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/service-jpgwire': minor
---

Allow custom lookup function and tls_servername. Also reduce dependencies.
3 changes: 3 additions & 0 deletions libs/lib-mongodb/src/db/mongo.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions libs/lib-mongodb/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LookupOptions, makeHostnameLookupFunction } from '@powersync/lib-services-framework';
import * as t from 'ts-codec';
import * as urijs from 'uri-js';

Expand All @@ -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<typeof BaseMongoConfig>;
Expand Down Expand Up @@ -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
};
}
1 change: 0 additions & 1 deletion libs/lib-postgres/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 18 additions & 19 deletions libs/lib-postgres/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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_"
Expand Down Expand Up @@ -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',
Expand All @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions libs/lib-postgres/src/utils/pgwire_utils.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
// 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';

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') {
Expand Down
2 changes: 1 addition & 1 deletion libs/lib-postgres/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"sourceMap": true
},
"include": ["src"],
"references": []
"references": [{ "path": "../../packages/jpgwire" }, { "path": "../lib-services" }]
}
1 change: 1 addition & 0 deletions libs/lib-services/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions libs/lib-services/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions libs/lib-services/src/ip/ip-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lookup.js';
118 changes: 118 additions & 0 deletions libs/lib-services/src/ip/lookup.ts
Original file line number Diff line number Diff line change
@@ -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<dns.LookupAddress> {
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;
}
2 changes: 2 additions & 0 deletions modules/module-mongodb/src/replication/MongoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions modules/module-mongodb/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -48,6 +49,8 @@ export interface NormalizedMongoConnectionConfig {
username?: string;
password?: string;

lookup?: LookupFunction;

postImages: PostImagesOption;
}

Expand Down
15 changes: 13 additions & 2 deletions modules/module-mysql/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(
Expand All @@ -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()
})
);

Expand Down Expand Up @@ -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',
Expand All @@ -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
};
}
1 change: 1 addition & 0 deletions modules/module-mysql/src/utils/mysql-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading