From 742009195a6c4fd92d1fe1ebf5cf7ab1d79d6b36 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 22 Nov 2024 01:09:28 +0100 Subject: [PATCH] feat: ensure multiple proxies work --- README.md | 44 ++++++++++++++++--- bun.lockb | Bin 189481 -> 189481 bytes package.json | 2 +- reverse-proxy.config.ts | 51 +++++++++------------ src/config.ts | 11 ----- src/https.ts | 95 +++++++++++++++++++++++++--------------- src/start.ts | 31 +++++++++---- src/types.ts | 30 ++++++++++--- 8 files changed, 166 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 14c1b0b..7eaa109 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,10 @@ startProxy(config) ### CLI ```bash -reverse-proxy --from localhost:3000 --to my-project.localhost -reverse-proxy --from localhost:8080 --to my-project.test --keyPath ./key.pem --certPath ./cert.pem -reverse-proxy --help -reverse-proxy --version +rpx --from localhost:3000 --to my-project.localhost +rpx --from localhost:8080 --to my-project.test --keyPath ./key.pem --certPath ./cert.pem +rpx --help +rpx --version ``` ## Configuration @@ -77,7 +77,7 @@ The Reverse Proxy can be configured using a `reverse-proxy.config.ts` _(or `reve ```ts // reverse-proxy.config.{ts,js} -import type { ReverseProxyOptions } from './src/types' +import type { ReverseProxyOptions } from '@stacksjs/rpx' import os from 'node:os' import path from 'node:path' @@ -106,6 +106,40 @@ const config: ReverseProxyOptions = { export default config ``` +In case you are trying to start multiple proxies, you may use this configuration: + +```ts +// reverse-proxy.config.{ts,js} +import type { ReverseProxyOptions } from '@stacksjs/rpx' +import os from 'node:os' +import path from 'node:path' + +const config: ReverseProxyOptions = { + https: { // https: true -> also works with sensible defaults + caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), + certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), + keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), + }, + + etcHostsCleanup: true, + + proxies: [ + { + from: 'localhost:5173', + to: 'my-app.localhost', + }, + { + from: 'localhost:5174', + to: 'my-api.local', + }, + ], + + verbose: true, +} + +export default config +``` + _Then run:_ ```bash diff --git a/bun.lockb b/bun.lockb index e4eaa536fcecdd26876a7c9c9cb3cbc56bd864a3..ed22af1183e175a827836c09433aa12c6e675ddf 100755 GIT binary patch delta 148 zcmV;F0BirL$P1~+3y>}#wu^#&7pm}}Xa&j4^1BC!&>x|Cgkw6g!33|D;i5U-+Oc)n3P}xS=qLe_woSt~tje%2-7dRt;BPQ(6 z!a}mxifj((^>r8@GPrRsQgKmz#AXr*)v1H+uea^50b(}+Hn&jc0fAltHn-A60#}}b Czd`u` delta 148 zcmV;F0BirL$P1~+3y>}#9eCM6V-PQkF*a%-+Do~OoCjddPDimxFp**VT}n}Eu}-QT zlQbSEli(c)vnU>gkw7T(D8MSg6{ItNr7*Om*@3c9ZFkpFwlB&%SXPOV%1~F0d10!n z`^Bj&TD!3Z5CT~l9ds~IRn*)f3|=hRaA1S&uea^50b(}+HMdab0fAltHMi150#}}E C7eCzq diff --git a/package.json b/package.json index 6bb9dc5..c46c0e3 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "preview:docs": "vitepress preview docs" }, "dependencies": { - "@stacksjs/tlsx": "^0.7.5" + "@stacksjs/tlsx": "^0.7.6" }, "devDependencies": { "@stacksjs/cli": "^0.68.2", diff --git a/reverse-proxy.config.ts b/reverse-proxy.config.ts index 13b41ad..c540968 100644 --- a/reverse-proxy.config.ts +++ b/reverse-proxy.config.ts @@ -1,37 +1,28 @@ import type { ReverseProxyOptions } from './src/types' +import os from 'node:os' +import path from 'node:path' -const config: ReverseProxyOptions = [ - { - from: 'localhost:5173', - to: 'test.localhost', - https: true, - // https: { - // domain: 'stacks.localhost', - // hostCertCN: 'stacks.localhost', - // caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), - // certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), - // keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), - // altNameIPs: ['127.0.0.1'], - // altNameURIs: ['localhost'], - // organizationName: 'stacksjs.org', - // countryName: 'US', - // stateName: 'California', - // localityName: 'Playa Vista', - // commonName: 'stacks.localhost', - // validityDays: 180, - // verbose: false, - // }, - verbose: true, +const config: ReverseProxyOptions = { + https: { + caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), + certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), + keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), }, - { - from: 'localhost:5174', - to: 'test.local', - https: true, - etcHostsCleanup: true, - verbose: true, - }, -] + etcHostsCleanup: true, + proxies: [ + { + from: 'localhost:5173', + to: 'test.localhost', + }, + { + from: 'localhost:5174', + to: 'test.local', + }, + ], + verbose: true, +} +// alternatively, you can use the following configuration // const config = { // from: 'localhost:5173', // to: 'test2.localhost', diff --git a/src/config.ts b/src/config.ts index 215106a..481c2fd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,20 +10,9 @@ export const config: ReverseProxyConfigs = await loadConfig({ from: 'localhost:5173', to: 'stacks.localhost', https: { - domain: 'stacks.localhost', - hostCertCN: 'stacks.localhost', caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), - altNameIPs: ['127.0.0.1'], - altNameURIs: ['localhost'], - organizationName: 'stacksjs.org', - countryName: 'US', - stateName: 'California', - localityName: 'Playa Vista', - commonName: 'stacks.localhost', - validityDays: 180, - verbose: false, }, etcHostsCleanup: true, verbose: true, diff --git a/src/https.ts b/src/https.ts index 8fcbad1..ede9b55 100644 --- a/src/https.ts +++ b/src/https.ts @@ -1,19 +1,25 @@ -import type { ReverseProxyOption, ReverseProxyOptions, TlsConfig } from './types' +import type { CustomTlsConfig, MultiReverseProxyConfig, ReverseProxyConfigs, TlsConfig } from './types' import os from 'node:os' import path from 'node:path' import { log } from '@stacksjs/cli' import { addCertToSystemTrustStoreAndSaveCert, createRootCA, generateCertificate as generateCert } from '@stacksjs/tlsx' +import { config } from './config' import { debugLog } from './utils' let cachedSSLConfig: { key: string, cert: string, ca?: string } | null = null -function extractDomains(options: ReverseProxyOptions): string[] { - if (Array.isArray(options)) { - return options.map((opt) => { - const domain = opt.to || 'stacks.localhost' +function isMultiProxyConfig(options: ReverseProxyConfigs): options is MultiReverseProxyConfig { + return 'proxies' in options +} + +function extractDomains(options: ReverseProxyConfigs): string[] { + if (isMultiProxyConfig(options)) { + return options.proxies.map((proxy) => { + const domain = proxy.to || 'stacks.localhost' return domain.startsWith('http') ? new URL(domain).hostname : domain }) } + const domain = options.to || 'stacks.localhost' return [domain.startsWith('http') ? new URL(domain).hostname : domain] } @@ -21,7 +27,7 @@ function extractDomains(options: ReverseProxyOptions): string[] { // Generate wildcard patterns for a domain function generateWildcardPatterns(domain: string): string[] { const patterns = new Set() - patterns.add(domain) // Add exact domain + patterns.add(domain) // Split domain into parts (e.g., "test.local" -> ["test", "local"]) const parts = domain.split('.') @@ -33,9 +39,26 @@ function generateWildcardPatterns(domain: string): string[] { return Array.from(patterns) } -function generateBaseConfig(domains: string[], verbose?: boolean): TlsConfig { +function generateBaseConfig(options: ReverseProxyConfigs, verbose?: boolean): TlsConfig { + const domains = extractDomains(options) const sslBase = path.join(os.homedir(), '.stacks', 'ssl') - + console.log('config.https', config.https) + const httpsConfig: Partial = options.https === true + ? { + caCertPath: path.join(sslBase, 'rpx-ca.crt'), + certPath: path.join(sslBase, 'rpx.crt'), + keyPath: path.join(sslBase, 'rpx.key'), + } + : typeof config.https === 'object' + ? { + ...options.https, + ...config.https, + } + : {} + + debugLog('ssl', `Extracted domains: ${domains.join(', ')}`, verbose) + debugLog('ssl', `Using SSL base path: ${sslBase}`, verbose) + debugLog('ssl', `Using HTTPS config: ${JSON.stringify(httpsConfig)}`, verbose) // Generate all possible SANs, including wildcards const allPatterns = new Set() domains.forEach((domain) => { @@ -54,31 +77,29 @@ function generateBaseConfig(domains: string[], verbose?: boolean): TlsConfig { debugLog('ssl', `Generated domain patterns: ${uniqueDomains.join(', ')}`, verbose) // Create a single object that contains all the config - const config: TlsConfig = { + return { + // Use the first domain for the certificate CN domain: domains[0], hostCertCN: domains[0], - caCertPath: path.join(sslBase, 'rpx-root-ca.crt'), - certPath: path.join(sslBase, 'rpx-certificate.crt'), - keyPath: path.join(sslBase, 'rpx-certificate.key'), - altNameIPs: ['127.0.0.1', '::1'], - // altNameURIs needs to be an empty array as we're using DNS names instead - altNameURIs: [], - // The real domains go in the commonName and subject alternative names - commonName: domains[0], - organizationName: 'RPX Local Development', - countryName: 'US', - stateName: 'California', - localityName: 'Playa Vista', - validityDays: 825, + caCertPath: httpsConfig?.caCertPath ?? path.join(sslBase, 'rpx-ca.crt'), + certPath: httpsConfig?.certPath ?? path.join(sslBase, 'rpx.crt'), + keyPath: httpsConfig?.keyPath ?? path.join(sslBase, 'rpx.key'), + altNameIPs: httpsConfig?.altNameIPs ?? ['127.0.0.1', '::1'], + altNameURIs: httpsConfig?.altNameURIs ?? [], + // Include all domains in the SAN + commonName: httpsConfig?.commonName ?? domains[0], + organizationName: httpsConfig?.organizationName ?? 'Local Development', + countryName: httpsConfig?.countryName ?? 'US', + stateName: httpsConfig?.stateName ?? 'California', + localityName: httpsConfig?.localityName ?? 'Playa Vista', + validityDays: httpsConfig?.validityDays ?? 825, verbose: verbose ?? false, - // Add Subject Alternative Names as DNS names + // Add all domains as Subject Alternative Names subjectAltNames: uniqueDomains.map(domain => ({ type: 2, // DNS type value: domain, })), - } - - return config + } satisfies TlsConfig } function generateRootCAConfig(): TlsConfig { @@ -102,21 +123,23 @@ function generateRootCAConfig(): TlsConfig { } } -export function httpsConfig(options: ReverseProxyOption | ReverseProxyOptions): TlsConfig { - const domains = extractDomains(options) - const verbose = Array.isArray(options) ? options[0]?.verbose : options.verbose - - return generateBaseConfig(domains, verbose) +export function httpsConfig(options: ReverseProxyConfigs): TlsConfig { + return generateBaseConfig(options, options.verbose) } -export async function generateCertificate(options: ReverseProxyOption | ReverseProxyOptions): Promise { +export async function generateCertificate(options: ReverseProxyConfigs): Promise { if (cachedSSLConfig) { - debugLog('ssl', 'Using cached SSL configuration', Array.isArray(options) ? options[0]?.verbose : options.verbose) + const verbose = isMultiProxyConfig(options) ? options.verbose : options.verbose + debugLog('ssl', 'Using cached SSL configuration', verbose) return } - const domains = extractDomains(options) - const verbose = Array.isArray(options) ? options[0]?.verbose : options.verbose + // Get all unique domains from the configuration + const domains = isMultiProxyConfig(options) + ? [options.proxies[0].to, ...options.proxies.map(proxy => proxy.to)] // Include the first domain from proxies array + : [options.to] + + const verbose = isMultiProxyConfig(options) ? options.verbose : options.verbose debugLog('ssl', `Generating certificate for domains: ${domains.join(', ')}`, verbose) @@ -126,7 +149,7 @@ export async function generateCertificate(options: ReverseProxyOption | ReverseP const caCert = await createRootCA(rootCAConfig) // Generate the host certificate with all domains - const hostConfig = generateBaseConfig(domains, verbose) + const hostConfig = generateBaseConfig(options, verbose) log.info(`Generating host certificate for: ${domains.join(', ')}`) const hostCert = await generateCert({ diff --git a/src/start.ts b/src/start.ts index d6e8160..ce7ced1 100644 --- a/src/start.ts +++ b/src/start.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import type { SecureServerOptions } from 'node:http2' import type { ServerOptions } from 'node:https' -import type { ProxySetupOptions, ReverseProxyConfig, ReverseProxyOption, ReverseProxyOptions, SSLConfig } from './types' +import type { BaseReverseProxyConfig, MultiReverseProxyConfig, ProxySetupOptions, ReverseProxyConfigs, ReverseProxyOption, ReverseProxyOptions, SingleReverseProxyConfig, SSLConfig } from './types' import * as fs from 'node:fs' import * as http from 'node:http' import * as https from 'node:https' @@ -216,7 +216,7 @@ async function testConnection(hostname: string, port: number, verbose?: boolean) }) } -export async function startServer(options: ReverseProxyConfig): Promise { +export async function startServer(options: SingleReverseProxyConfig): Promise { debugLog('server', `Starting server with options: ${JSON.stringify(options)}`, options.verbose) // Parse URLs early to get the hostnames @@ -538,7 +538,7 @@ export function startHttpRedirectServer(verbose?: boolean): void { export function startProxy(options: ReverseProxyOption): void { debugLog('proxy', `Starting proxy with options: ${JSON.stringify(options)}`, options?.verbose) - const serverOptions: ReverseProxyConfig = { + const serverOptions: SingleReverseProxyConfig = { from: options?.from || 'localhost:5173', to: options?.to || 'stacks.localhost', https: httpsConfig(options), @@ -563,16 +563,25 @@ export async function startProxies(options?: ReverseProxyOptions): Promise if (!options) return - debugLog('proxies', `Starting proxies setup`, Array.isArray(options) ? options[0]?.verbose : options.verbose) + debugLog('proxies', 'Starting proxies setup', isMultiProxyConfig(options) ? options.verbose : options.verbose) - // Convert single option to array for consistent handling - const proxyOptions = Array.isArray(options) ? options : [options] + console.log('options', options) - // Generate certificate once for all domains - if (proxyOptions.some(opt => opt.https)) { - await generateCertificate(proxyOptions) + if (options.https) { + await generateCertificate(options as ReverseProxyConfigs) } + // Convert configurations to a flat array of proxy configs + const proxyOptions = isMultiProxyConfig(options) + ? options.proxies.map(proxy => ({ + ...proxy, + https: options.https, + etcHostsCleanup: options.etcHostsCleanup, + verbose: options.verbose, + _cachedSSLConfig: options._cachedSSLConfig, + })) + : [options] + // Now start all proxies with the cached SSL config for (const option of proxyOptions) { try { @@ -601,3 +610,7 @@ export async function startProxies(options?: ReverseProxyOptions): Promise } } } + +function isMultiProxyConfig(options: ReverseProxyConfigs): options is MultiReverseProxyConfig { + return 'proxies' in options +} diff --git a/src/types.ts b/src/types.ts index 4c89efe..6267c73 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,17 +1,35 @@ import type { TlsConfig } from '@stacksjs/tlsx' -export interface ReverseProxyConfig { +export type CustomTlsConfig = Partial> & + Pick + +export interface BaseReverseProxyConfig { from: string // localhost:5173 to: string // stacks.localhost - https: boolean | TlsConfig +} + +export interface SharedProxySettings { + https: boolean | CustomTlsConfig etcHostsCleanup: boolean verbose: boolean - _cachedSSLConfig?: SSLConfig | null // Add this line + _cachedSSLConfig?: SSLConfig | null } -export type ReverseProxyConfigs = ReverseProxyConfig | ReverseProxyConfig[] -export type ReverseProxyOption = Partial -export type ReverseProxyOptions = ReverseProxyOption | ReverseProxyOption[] +export interface SingleReverseProxyConfig extends BaseReverseProxyConfig, SharedProxySettings {} + +export interface MultiReverseProxyConfig extends SharedProxySettings { + proxies: BaseReverseProxyConfig[] +} + +export type ReverseProxyConfigs = SingleReverseProxyConfig | MultiReverseProxyConfig + +export type BaseReverseProxyOption = Partial +export type PartialSharedSettings = Partial + +export type MultiReverseProxyOption = MultiReverseProxyConfig + +export type ReverseProxyOption = SingleReverseProxyConfig +export type ReverseProxyOptions = SingleReverseProxyConfig | MultiReverseProxyOption export interface SSLConfig { key: string