@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/dist/bin/cli.js +129 -128
- package/dist/daemon-runner.d.ts +3 -0
- package/dist/https.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +73 -72
- package/package.json +1 -1
- package/src/daemon-runner.ts +15 -2
- package/src/daemon.ts +29 -7
- package/src/https.ts +49 -19
- package/src/index.ts +1 -0
package/package.json
CHANGED
package/src/daemon-runner.ts
CHANGED
|
@@ -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
|
|
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
|
|
160
|
-
* `
|
|
161
|
-
*
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
752
|
+
const keychains = [
|
|
753
|
+
'/Library/Keychains/System.keychain',
|
|
754
|
+
join(homedir(), 'Library/Keychains/login.keychain-db'),
|
|
755
|
+
]
|
|
733
756
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
770
|
+
debugLog('ssl', 'Certificate fingerprint not found in system keychains', options?.verbose)
|
|
741
771
|
return false
|
|
742
772
|
}
|
|
743
773
|
catch (error) {
|