@stacksjs/rpx 0.11.7 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/rpx",
3
3
  "type": "module",
4
- "version": "0.11.7",
4
+ "version": "0.11.8",
5
5
  "description": "A modern and smart reverse proxy.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -42,6 +42,16 @@ export interface DaemonRunnerOptions {
42
42
  detached?: boolean
43
43
  /** Override the daemon spawn command (tests). */
44
44
  spawnCommand?: string[]
45
+ /** Passed through to `ensureDaemonRunning` (default 5000ms). */
46
+ startupTimeoutMs?: number
47
+ /** Extra env for the daemon child (e.g. `SUDO_PASSWORD`). */
48
+ spawnEnv?: Record<string, string>
49
+ /**
50
+ * When true, registry entries omit `pid` so they persist until
51
+ * `rpx unregister` — avoids PID-GC dropping routes when the registering
52
+ * process is short-lived (e.g. a CLI wrapper).
53
+ */
54
+ persistent?: boolean
45
55
  }
46
56
 
47
57
  /**
@@ -78,14 +88,15 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
78
88
  return { ...p, id }
79
89
  })
80
90
 
91
+ const createdAt = new Date().toISOString()
81
92
  for (const p of resolved) {
82
93
  await writeEntry({
83
94
  id: p.id,
84
95
  from: p.from,
85
96
  to: p.to,
86
- pid: process.pid,
97
+ pid: opts.persistent ? undefined : process.pid,
87
98
  cwd: process.cwd(),
88
- createdAt: new Date().toISOString(),
99
+ createdAt,
89
100
  cleanUrls: p.cleanUrls,
90
101
  changeOrigin: p.changeOrigin,
91
102
  pathRewrites: p.pathRewrites,
@@ -96,6 +107,8 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
96
107
  rpxDir: opts.rpxDir,
97
108
  verbose,
98
109
  spawnCommand: opts.spawnCommand,
110
+ startupTimeoutMs: opts.startupTimeoutMs,
111
+ spawnEnv: opts.spawnEnv,
99
112
  })
100
113
 
101
114
  for (const p of resolved)
package/src/daemon.ts CHANGED
@@ -156,16 +156,38 @@ function entryToRoute(entry: RegistryEntry): ProxyRoute {
156
156
  * Bootstrap the daemon's TLS material. Reuses the persisted Root CA and any
157
157
  * existing trusted host cert; mints fresh ones if none exist.
158
158
  *
159
- * The host cert is issued with the standard `*.localhost` SAN list (set by
160
- * `httpsConfig` via `getAllDomains`), so every `<app>.localhost` route is
161
- * covered without needing to regenerate when apps register.
159
+ * The host cert SAN list includes every hostname in the registry (e.g.
160
+ * `postline.localhost`, `api.postline.localhost`). Chrome does not treat
161
+ * `*.localhost` as matching `<app>.localhost`, so those names must be explicit.
162
162
  */
163
- async function bootstrapTls(opts: DaemonOptions): Promise<SSLConfig> {
163
+ function pickPrimaryRegistryHost(hosts: string[]): string {
164
+ const appHost = hosts.find(h => !/^api\./.test(h) && !/^docs\./.test(h) && !/^dashboard\./.test(h))
165
+ return appHost ?? hosts[0] ?? 'rpx.localhost'
166
+ }
167
+
168
+ async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<SSLConfig> {
169
+ const entries = await readAll(registryDir, opts.verbose)
170
+ const registryHosts = [...new Set(entries.map(e => e.to))]
171
+ const primary = pickPrimaryRegistryHost(registryHosts)
172
+ const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
173
+
174
+ const sslDir = path.join(homedir(), '.stacks', 'ssl')
175
+ const sharedCert = path.join(sslDir, 'rpx.localhost.crt')
176
+
164
177
  const proxyOpts: ProxyOptions = {
165
- https: opts.https ?? true,
166
- to: 'rpx.localhost',
178
+ https: typeof opts.https === 'object'
179
+ ? { ...opts.https, certPath: sharedCert, keyPath: path.join(sslDir, 'rpx.localhost.key'), commonName: primary }
180
+ : {
181
+ certPath: sharedCert,
182
+ keyPath: path.join(sslDir, 'rpx.localhost.key'),
183
+ caCertPath: path.join(sslDir, 'rpx.localhost.ca.crt'),
184
+ commonName: primary,
185
+ },
167
186
  verbose: opts.verbose,
168
187
  regenerateUntrustedCerts: true,
188
+ ...(hostnames.length > 1
189
+ ? { proxies: hostnames.map(to => ({ from: 'localhost:1', to })) }
190
+ : { to: primary, from: 'localhost:1' }),
169
191
  }
170
192
 
171
193
  let sslConfig = await checkExistingCertificates(proxyOpts)
@@ -216,7 +238,7 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
216
238
  })
217
239
  rebuild(await readAll(registryDir, verbose))
218
240
 
219
- const sslConfig = await bootstrapTls(opts)
241
+ const sslConfig = await bootstrapTls(opts, registryDir)
220
242
 
221
243
  const httpsServer = Bun.serve({
222
244
  port: httpsPort,
package/src/https.ts CHANGED
@@ -284,10 +284,19 @@ async function forceTrustCertificateMacOS(certPath: string): Promise<boolean> {
284
284
 
285
285
  debugLog('ssl', 'Trusting certificate via macOS security command', false)
286
286
 
287
- // Use execSudoSync which handles SUDO_PASSWORD from env
287
+ // Login keychain no sudo; Chrome/Arc often read this store on macOS.
288
+ const loginKeychain = join(homedir(), 'Library/Keychains/login.keychain-db')
288
289
  try {
289
- execSudoSync(`security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`)
290
- return true
290
+ execSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k "${loginKeychain}" "${certPath}"`, { stdio: 'ignore' })
291
+ if (await isCertTrusted(certPath, { verbose: false, regenerateUntrustedCerts: true }))
292
+ return true
293
+ }
294
+ catch { /* fall through to system keychain */ }
295
+
296
+ // System keychain — requires sudo / SUDO_PASSWORD.
297
+ try {
298
+ execSudoSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${certPath}"`)
299
+ return await isCertTrusted(certPath, { verbose: false, regenerateUntrustedCerts: true })
291
300
  }
292
301
  catch {
293
302
  return false
@@ -299,10 +308,11 @@ async function forceTrustCertificateMacOS(certPath: string): Promise<boolean> {
299
308
  }
300
309
 
301
310
  export async function generateCertificate(options: ProxyOptions): Promise<void> {
302
- if (cachedSSLConfig) {
311
+ if (cachedSSLConfig && !(options as { forceRegenerate?: boolean }).forceRegenerate) {
303
312
  debugLog('ssl', 'Using cached SSL configuration', options.verbose)
304
313
  return
305
314
  }
315
+ clearSslConfigCache()
306
316
 
307
317
  // Get all unique domains from the configuration
308
318
  const domains: string[] = isMultiProxyOptions(options)
@@ -403,7 +413,7 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
403
413
  // We install only the Root CA — host certs derive their trust from it.
404
414
  if (process.platform === 'darwin') {
405
415
  try {
406
- execSudoSync(`security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"`)
416
+ execSudoSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"`)
407
417
  if (options.verbose)
408
418
  log.success('Successfully added Root CA to system trust store')
409
419
 
@@ -413,7 +423,7 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
413
423
  const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
414
424
  const scriptContent = `#!/bin/bash
415
425
  echo "Trusting RPX Root CA"
416
- sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
426
+ sudo security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
417
427
  echo "Root CA trusted! Please restart your browser."
418
428
  echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
419
429
  `
@@ -426,7 +436,7 @@ echo "If you still see certificate warnings, type 'thisisunsafe' on the warning
426
436
  const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
427
437
  const scriptContent = `#!/bin/bash
428
438
  echo "Trusting RPX Root CA"
429
- sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
439
+ sudo security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
430
440
  echo "Root CA trusted! Please restart your browser."
431
441
  echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
432
442
  `
@@ -539,6 +549,11 @@ export function getSSLConfig(): { key: string, cert: string, ca?: string } | nul
539
549
  return cachedSSLConfig
540
550
  }
541
551
 
552
+ /** Clear in-process TLS cache so the next generate/load picks up new files on disk. */
553
+ export function clearSslConfigCache(): void {
554
+ cachedSSLConfig = null
555
+ }
556
+
542
557
  // needs to accept the options
543
558
  export async function checkExistingCertificates(options?: ProxyOptions): Promise<SSLConfig | null> {
544
559
  if (!options)
@@ -569,10 +584,15 @@ export async function checkExistingCertificates(options?: ProxyOptions): Promise
569
584
  const flagValue = options.regenerateUntrustedCerts
570
585
  const shouldCheckTrust = hasFlag ? flagValue !== false : true
571
586
  debugLog('ssl', `Trust check: hasFlag=${hasFlag}, flagValue=${flagValue}, shouldCheckTrust=${shouldCheckTrust}`, options.verbose)
572
- const certIsTrusted = shouldCheckTrust ? await isCertTrusted(sslConfig.certPath, options) : true
573
-
574
- if (!certIsTrusted) {
575
- debugLog('ssl', 'Certificate exists but is not trusted, will regenerate', options.verbose)
587
+ // Trust anchor is the shared Root CA host certs are not installed individually.
588
+ const sslDir = sslConfig.basePath || join(homedir(), '.stacks', 'ssl')
589
+ const rootCAPaths = getRootCAPaths(sslDir)
590
+ const caIsTrusted = shouldCheckTrust
591
+ ? await isCertTrusted(rootCAPaths.caCertPath, options)
592
+ : true
593
+
594
+ if (!caIsTrusted) {
595
+ debugLog('ssl', 'Root CA exists but is not trusted, will regenerate', options.verbose)
576
596
  // Don't attempt to trust here - let generateCertificate handle it in one place
577
597
  // This avoids multiple sudo prompts
578
598
  return null
@@ -721,23 +741,33 @@ export async function isCertTrusted(certPath: string, options?: { verbose?: bool
721
741
  try {
722
742
  // Get certificate fingerprint
723
743
  const certFingerprint = execSync(`openssl x509 -noout -fingerprint -sha256 -in "${certPath}"`).toString().trim()
724
- const fingerprintValue = certFingerprint.split('=')[1]?.trim() || ''
744
+ const normalize = (raw: string) => raw.split('=').pop()!.replace(/SHA-256\s+hash:\s*/gi, '').replace(/:/g, '').trim().toUpperCase()
745
+ const fingerprintValue = normalize(certFingerprint)
725
746
 
726
747
  if (!fingerprintValue) {
727
748
  debugLog('ssl', 'Could not extract certificate fingerprint', options?.verbose)
728
749
  return false
729
750
  }
730
751
 
731
- // Check if the fingerprint exists in the system keychain and is trusted
732
- const keychainOutput = execSync(`security find-certificate -a -Z -p | openssl x509 -noout -fingerprint -sha256`).toString()
752
+ const keychains = [
753
+ '/Library/Keychains/System.keychain',
754
+ join(homedir(), 'Library/Keychains/login.keychain-db'),
755
+ ]
733
756
 
734
- // If the fingerprint is found in the trusted certs, consider it trusted
735
- if (keychainOutput.includes(fingerprintValue)) {
736
- debugLog('ssl', 'Certificate fingerprint found in system keychain', options?.verbose)
737
- return true
757
+ for (const keychain of keychains) {
758
+ try {
759
+ const listing = execSync(`security find-certificate -a -Z "${keychain}" 2>/dev/null || true`).toString()
760
+ for (const line of listing.split('\n')) {
761
+ if (line.toUpperCase().includes('SHA-256') && normalize(line) === fingerprintValue) {
762
+ debugLog('ssl', `Certificate fingerprint found in ${keychain}`, options?.verbose)
763
+ return true
764
+ }
765
+ }
766
+ }
767
+ catch { /* try next keychain */ }
738
768
  }
739
769
 
740
- debugLog('ssl', 'Certificate fingerprint not found in system keychain', options?.verbose)
770
+ debugLog('ssl', 'Certificate fingerprint not found in system keychains', options?.verbose)
741
771
  return false
742
772
  }
743
773
  catch (error) {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  export {
14
14
  checkExistingCertificates,
15
15
  cleanupCertificates,
16
+ clearSslConfigCache,
16
17
  forceTrustCertificate,
17
18
  generateCertificate,
18
19
  httpsConfig,