Skip to content

Commit

Permalink
feat: ensure multiple proxies work
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisbbreuer committed Nov 22, 2024
1 parent b519c5b commit 7420091
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 98 deletions.
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'

Expand Down Expand Up @@ -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
Expand Down
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 21 additions & 30 deletions reverse-proxy.config.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
11 changes: 0 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 59 additions & 36 deletions src/https.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
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]
}

// Generate wildcard patterns for a domain
function generateWildcardPatterns(domain: string): string[] {
const patterns = new Set<string>()
patterns.add(domain) // Add exact domain
patterns.add(domain)

// Split domain into parts (e.g., "test.local" -> ["test", "local"])
const parts = domain.split('.')
Expand All @@ -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<CustomTlsConfig> = 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<string>()
domains.forEach((domain) => {
Expand All @@ -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 {
Expand All @@ -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<void> {
export async function generateCertificate(options: ReverseProxyConfigs): Promise<void> {
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)

Expand All @@ -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({
Expand Down
31 changes: 22 additions & 9 deletions src/start.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -216,7 +216,7 @@ async function testConnection(hostname: string, port: number, verbose?: boolean)
})
}

export async function startServer(options: ReverseProxyConfig): Promise<void> {
export async function startServer(options: SingleReverseProxyConfig): Promise<void> {
debugLog('server', `Starting server with options: ${JSON.stringify(options)}`, options.verbose)

// Parse URLs early to get the hostnames
Expand Down Expand Up @@ -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),
Expand All @@ -563,16 +563,25 @@ export async function startProxies(options?: ReverseProxyOptions): Promise<void>
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 {
Expand Down Expand Up @@ -601,3 +610,7 @@ export async function startProxies(options?: ReverseProxyOptions): Promise<void>
}
}
}

function isMultiProxyConfig(options: ReverseProxyConfigs): options is MultiReverseProxyConfig {
return 'proxies' in options
}
Loading

0 comments on commit 7420091

Please sign in to comment.