@stacksjs/rpx 0.11.18 → 0.11.20

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.
Files changed (46) hide show
  1. package/dist/bin/cli.js +225 -210
  2. package/dist/cert-inspect.d.ts +5 -3
  3. package/dist/chunk-0f32jmrb.js +1 -0
  4. package/dist/chunk-bs9e342s.js +1 -0
  5. package/dist/chunk-vxj1ckdm.js +176 -0
  6. package/dist/{chunk-rbgb5fyg.js → chunk-w888yhnp.js} +12 -12
  7. package/dist/daemon.d.ts +17 -8
  8. package/dist/dns.d.ts +2 -0
  9. package/dist/https.d.ts +10 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.js +7 -7
  12. package/dist/logger.d.ts +8 -8
  13. package/dist/proxy-handler.d.ts +1 -1
  14. package/dist/proxy-pool.d.ts +82 -0
  15. package/dist/start.d.ts +31 -8
  16. package/dist/types.d.ts +13 -0
  17. package/dist/utils.d.ts +15 -0
  18. package/package.json +11 -7
  19. package/dist/chunk-9njrhbd4.js +0 -1
  20. package/dist/chunk-a0b9f9fs.js +0 -1
  21. package/dist/chunk-s4tdtq37.js +0 -161
  22. package/src/cert-inspect.ts +0 -69
  23. package/src/colors.ts +0 -13
  24. package/src/config.ts +0 -45
  25. package/src/daemon-runner.ts +0 -180
  26. package/src/daemon.ts +0 -786
  27. package/src/dns-state.ts +0 -116
  28. package/src/dns.ts +0 -509
  29. package/src/host-match.ts +0 -52
  30. package/src/host-routes.ts +0 -147
  31. package/src/hosts.ts +0 -278
  32. package/src/https.ts +0 -862
  33. package/src/index.ts +0 -158
  34. package/src/logger.ts +0 -19
  35. package/src/macos-trust.ts +0 -175
  36. package/src/on-demand.ts +0 -264
  37. package/src/origin-guard.ts +0 -105
  38. package/src/port-manager.ts +0 -183
  39. package/src/process-manager.ts +0 -164
  40. package/src/proxy-handler.ts +0 -315
  41. package/src/registry.ts +0 -366
  42. package/src/sni.ts +0 -93
  43. package/src/start.ts +0 -1421
  44. package/src/static-files.ts +0 -201
  45. package/src/types.ts +0 -256
  46. package/src/utils.ts +0 -210
package/src/https.ts DELETED
@@ -1,862 +0,0 @@
1
- import type { ProxyConfigs, ProxyOption, ProxyOptions, SingleProxyConfig, SSLConfig, TlsConfig } from './types'
2
- import { execSync } from 'node:child_process'
3
- import fs from 'node:fs/promises'
4
- import * as os from 'node:os'
5
- import { homedir } from 'node:os'
6
- import * as path from 'node:path'
7
- import { join } from 'node:path'
8
- import * as process from 'node:process'
9
- import { addCertToSystemTrustStoreAndSaveCert, createRootCA, generateCertificate as generateCert } from '@stacksjs/tlsx'
10
- import { log } from './logger'
11
- import { config } from './config'
12
- import {
13
- MACOS_CA_TRUST_FLAGS,
14
- MACOS_SYSTEM_KEYCHAIN,
15
- getMacosLoginKeychainPath,
16
- isRootCaFingerprintInKeychains,
17
- isRootCaTrustedForSsl,
18
- pruneStaleRootCas,
19
- trustRootCaForBrowsers,
20
- } from './macos-trust'
21
- import { debugLog, execSudoSync, getPrimaryDomain, isMultiProxyConfig, isMultiProxyOptions, isSingleProxyOptions, isValidRootCA, safeDeleteFile } from './utils'
22
-
23
- export {
24
- MACOS_CA_TRUST_FLAGS,
25
- MACOS_SYSTEM_KEYCHAIN,
26
- RPX_ROOT_CA_COMMON_NAME,
27
- getMacosLoginKeychainPath,
28
- getMacosTrustKeychains,
29
- isRootCaFingerprintInKeychains,
30
- isRootCaTrustedForSsl,
31
- listCertSha256HashesByCommonName,
32
- pruneStaleRootCas,
33
- trustRootCaForBrowsers,
34
- } from './macos-trust'
35
-
36
- export {
37
- certIncludesSanHostnames,
38
- normalizeSha256Fingerprint,
39
- parseSha256HashesFromSecurityListing,
40
- readCertCommonName,
41
- readCertSha256Fingerprint,
42
- verifyHttpsChain,
43
- } from './cert-inspect'
44
-
45
- let cachedSSLConfig: { key: string, cert: string, ca?: string } | null = null
46
-
47
- // Canonical filenames for the shared Root CA. The CA is a singleton across all
48
- // rpx-managed domains so that browsers only need to trust it once. Host certs
49
- // for individual domains are issued from this CA on demand.
50
- const ROOT_CA_CERT_FILENAME = 'rpx-root-ca.crt'
51
- const ROOT_CA_KEY_FILENAME = 'rpx-root-ca.key'
52
-
53
- export interface RootCAPaths {
54
- caCertPath: string
55
- caKeyPath: string
56
- }
57
-
58
- /**
59
- * Returns the canonical Root CA cert + key paths inside `basePath`.
60
- */
61
- export function getRootCAPaths(basePath: string): RootCAPaths {
62
- return {
63
- caCertPath: join(basePath, ROOT_CA_CERT_FILENAME),
64
- caKeyPath: join(basePath, ROOT_CA_KEY_FILENAME),
65
- }
66
- }
67
-
68
- /** Paths for the shared multi-host daemon cert under `~/.stacks/ssl`. */
69
- export function getSharedDaemonCertPaths(sslDir: string): {
70
- certPath: string
71
- keyPath: string
72
- caCertPath: string
73
- rootCA: RootCAPaths
74
- } {
75
- return {
76
- certPath: join(sslDir, 'rpx.localhost.crt'),
77
- keyPath: join(sslDir, 'rpx.localhost.key'),
78
- caCertPath: join(sslDir, 'rpx.localhost.ca.crt'),
79
- rootCA: getRootCAPaths(sslDir),
80
- }
81
- }
82
-
83
- /**
84
- * Load a previously-persisted Root CA (cert + private key). Returns `null` if
85
- * either file is missing or unreadable, signalling that a fresh CA needs to be
86
- * created.
87
- */
88
- async function loadRootCA(paths: RootCAPaths, verbose?: boolean): Promise<{ certificate: string, privateKey: string } | null> {
89
- try {
90
- const [certificate, privateKey] = await Promise.all([
91
- fs.readFile(paths.caCertPath, 'utf8'),
92
- fs.readFile(paths.caKeyPath, 'utf8'),
93
- ])
94
- if (!certificate.includes('-----BEGIN CERTIFICATE-----') || !privateKey.includes('PRIVATE KEY-----')) {
95
- debugLog('ssl', `Root CA files at ${paths.caCertPath} look malformed, will regenerate`, verbose)
96
- return null
97
- }
98
- return { certificate, privateKey }
99
- }
100
- catch (err) {
101
- debugLog('ssl', `No existing Root CA at ${paths.caCertPath} (${(err as NodeJS.ErrnoException).code || err}), will create one`, verbose)
102
- return null
103
- }
104
- }
105
-
106
- /**
107
- * Resolves SSL paths based on configuration
108
- */
109
- export function resolveSSLPaths(options: ProxyConfigs, defaultConfig: typeof config): TlsConfig {
110
- const domain = isMultiProxyConfig(options)
111
- ? options.proxies[0].to || 'rpx.localhost'
112
- : options.to || 'rpx.localhost'
113
-
114
- // If HTTPS is an object and has explicit paths defined, use those
115
- if (typeof options.https === 'object' && typeof defaultConfig.https === 'object') {
116
- const hasAllPaths = options.https.caCertPath && options.https.certPath && options.https.keyPath
117
- if (hasAllPaths) {
118
- // Create base TLS config
119
- const baseConfig = httpsConfig({
120
- ...options,
121
- to: domain,
122
- https: defaultConfig.https,
123
- })
124
-
125
- // Filter out undefined values from arrays
126
- const altNameIPs = options.https.altNameIPs?.filter((ip: any): ip is string => ip !== undefined) || baseConfig.altNameIPs
127
- const altNameURIs = options.https.altNameURIs?.filter((uri: any): uri is string => uri !== undefined) || baseConfig.altNameURIs
128
-
129
- // Override with provided paths
130
- return {
131
- ...baseConfig,
132
- caCertPath: options.https.caCertPath || baseConfig.caCertPath,
133
- certPath: options.https.certPath || baseConfig.certPath,
134
- keyPath: options.https.keyPath || baseConfig.keyPath,
135
- basePath: options.https.basePath || baseConfig.basePath,
136
- commonName: options.https.commonName || baseConfig.commonName,
137
- organizationName: options.https.organizationName || baseConfig.organizationName,
138
- countryName: options.https.countryName || baseConfig.countryName,
139
- stateName: options.https.stateName || baseConfig.stateName,
140
- localityName: options.https.localityName || baseConfig.localityName,
141
- validityDays: options.https.validityDays || baseConfig.validityDays,
142
- altNameIPs,
143
- altNameURIs,
144
- verbose: options.verbose || baseConfig.verbose,
145
- }
146
- }
147
- }
148
-
149
- // Otherwise, generate paths based on the domain
150
- return httpsConfig({
151
- ...options,
152
- to: domain,
153
- })
154
- }
155
-
156
- // Generate wildcard patterns for a domain
157
- export function generateWildcardPatterns(domain: string): string[] {
158
- const patterns = new Set<string>()
159
- patterns.add(domain)
160
-
161
- const parts = domain.split('.')
162
- if (parts.length >= 2)
163
- patterns.add(`*.${parts.slice(1).join('.')}`)
164
-
165
- return Array.from(patterns)
166
- }
167
-
168
- /**
169
- * Generates SSL file paths based on domain
170
- */
171
- export function generateSSLPaths(options?: ProxyOptions): {
172
- caCertPath: string
173
- certPath: string
174
- keyPath: string
175
- } {
176
- const domain = getPrimaryDomain(options)
177
- const sanitizedDomain = domain.replace(/\*/g, 'wildcard')
178
-
179
- // Get basePath from options or use default
180
- const defaultBasePath = join(homedir(), '.stacks', 'ssl')
181
- let basePath = defaultBasePath
182
-
183
- if (typeof options?.https === 'object') {
184
- // Only use options.https.basePath if it's a non-empty string
185
- basePath = options.https.basePath && options.https.basePath.trim() !== ''
186
- ? options.https.basePath
187
- : defaultBasePath
188
-
189
- return {
190
- caCertPath: options.https.caCertPath || join(basePath, `${sanitizedDomain}.ca.crt`),
191
- certPath: options.https.certPath || join(basePath, `${sanitizedDomain}.crt`),
192
- keyPath: options.https.keyPath || join(basePath, `${sanitizedDomain}.key`),
193
- }
194
- }
195
-
196
- return {
197
- caCertPath: join(basePath, `${sanitizedDomain}.ca.crt`),
198
- certPath: join(basePath, `${sanitizedDomain}.crt`),
199
- keyPath: join(basePath, `${sanitizedDomain}.key`),
200
- }
201
- }
202
-
203
- export function getAllDomains(options: ProxyOption | ProxyOptions): Set<string> {
204
- const domains = new Set<string>()
205
-
206
- if (isMultiProxyOptions(options)) {
207
- options.proxies.forEach((proxy) => {
208
- const domain = proxy.to || 'rpx.localhost'
209
- generateWildcardPatterns(domain).forEach(pattern => domains.add(pattern))
210
- })
211
- }
212
- else if (isSingleProxyOptions(options)) {
213
- const domain = options.to || 'rpx.localhost'
214
- generateWildcardPatterns(domain).forEach(pattern => domains.add(pattern))
215
- }
216
- else {
217
- domains.add('rpx.localhost')
218
- }
219
-
220
- // Add localhost patterns
221
- domains.add('localhost')
222
- domains.add('*.localhost')
223
-
224
- return domains
225
- }
226
-
227
- /**
228
- * Load SSL certificates from files or use provided strings
229
- */
230
- export async function loadSSLConfig(options: ProxyOption): Promise<SSLConfig | null> {
231
- debugLog('ssl', `Loading SSL configuration`, options.verbose)
232
-
233
- const mergedOptions = {
234
- ...config,
235
- ...options,
236
- }
237
-
238
- options.https = httpsConfig(mergedOptions)
239
-
240
- // Early return for non-SSL configuration
241
- if (!options.https?.keyPath && !options.https?.certPath) {
242
- debugLog('ssl', 'No SSL configuration provided', options.verbose)
243
- return null
244
- }
245
-
246
- if ((options.https?.keyPath && !options.https?.certPath) || (!options.https?.keyPath && options.https?.certPath)) {
247
- const missing = !options.https?.keyPath ? 'keyPath' : 'certPath'
248
- debugLog('ssl', `Invalid SSL configuration - missing ${missing}`, options.verbose)
249
- throw new Error(`SSL Configuration requires both keyPath and certPath. Missing: ${missing}`)
250
- }
251
-
252
- try {
253
- if (!options.https?.keyPath || !options.https?.certPath)
254
- return null
255
-
256
- // Try to read existing certificates
257
- try {
258
- debugLog('ssl', 'Reading SSL certificate files', options.verbose)
259
- const key = await fs.readFile(options.https?.keyPath, 'utf8')
260
- const cert = await fs.readFile(options.https?.certPath, 'utf8')
261
-
262
- debugLog('ssl', 'SSL configuration loaded successfully', options.verbose)
263
- return { key, cert }
264
- }
265
- catch (error) {
266
- debugLog('ssl', `Failed to read certificates: ${error}`, options.verbose)
267
- return null
268
- }
269
- }
270
- catch (err) {
271
- debugLog('ssl', `SSL configuration error: ${err}`, options.verbose)
272
- throw err
273
- }
274
- }
275
-
276
- /**
277
- * Force trust a certificate - exposing for direct use
278
- */
279
- export async function forceTrustCertificate(
280
- certPath: string,
281
- options?: { serverName?: string, verbose?: boolean },
282
- ): Promise<boolean> {
283
- if (process.platform === 'darwin')
284
- return forceTrustCertificateMacOS(certPath, options)
285
-
286
- if (process.platform === 'linux') {
287
- try {
288
- const { exec } = await import('node:child_process')
289
- return await new Promise((resolve) => {
290
- // Try Ubuntu/Debian way
291
- exec(
292
- `sudo cp "${certPath}" /usr/local/share/ca-certificates/ && sudo update-ca-certificates`,
293
- (error) => {
294
- if (!error) {
295
- resolve(true)
296
- }
297
- else {
298
- // Try Fedora/RHEL way
299
- exec(
300
- `sudo cp "${certPath}" /etc/pki/ca-trust/source/anchors/ && sudo update-ca-trust extract`,
301
- (err2) => {
302
- resolve(!err2)
303
- },
304
- )
305
- }
306
- },
307
- )
308
- })
309
- }
310
- catch {
311
- return false
312
- }
313
- }
314
-
315
- return false
316
- }
317
-
318
- /**
319
- * Force trust a certificate on macOS using direct security command
320
- * This function first checks if the certificate is already trusted to avoid unnecessary sudo prompts
321
- */
322
- async function forceTrustCertificateMacOS(
323
- certPath: string,
324
- options?: { serverName?: string, verbose?: boolean },
325
- ): Promise<boolean> {
326
- if (process.platform !== 'darwin')
327
- return false
328
-
329
- const serverName = options?.serverName ?? 'rpx.localhost'
330
- const verbose = options?.verbose ?? false
331
-
332
- try {
333
- if (isRootCaTrustedForSsl(certPath, serverName, { verbose })) {
334
- debugLog('ssl', 'Root CA already trusted for SSL, skipping trust operation', verbose)
335
- return true
336
- }
337
-
338
- debugLog('ssl', 'Trusting Root CA for browsers (login + system keychains)', verbose)
339
- return trustRootCaForBrowsers(certPath, { serverName, verbose })
340
- }
341
- catch {
342
- return false
343
- }
344
- }
345
-
346
- export async function generateCertificate(options: ProxyOptions): Promise<void> {
347
- if (cachedSSLConfig && !(options as { forceRegenerate?: boolean }).forceRegenerate) {
348
- debugLog('ssl', 'Using cached SSL configuration', options.verbose)
349
- return
350
- }
351
- clearSslConfigCache()
352
-
353
- // Get all unique domains from the configuration
354
- const domains: string[] = isMultiProxyOptions(options)
355
- ? options.proxies.map(proxy => proxy.to)
356
- : [(options as SingleProxyConfig).to]
357
-
358
- debugLog('ssl', `Generating certificate for domains: ${domains.join(', ')}`, options.verbose)
359
-
360
- const hostConfig = httpsConfig(options, options.verbose)
361
- const sslDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
362
- await fs.mkdir(sslDir, { recursive: true })
363
- const rootCAPaths = getRootCAPaths(sslDir)
364
-
365
- // Reuse the persisted Root CA when present so the user only has to trust it
366
- // once. A fresh CA gets created (and persisted) on first run.
367
- let caCert = await loadRootCA(rootCAPaths, options.verbose)
368
- let caIsNew = false
369
- if (!caCert) {
370
- if (options.verbose)
371
- log.info('Generating Root CA certificate (one-time)...')
372
- caCert = await createRootCA(hostConfig)
373
- try {
374
- await Promise.all([
375
- fs.writeFile(rootCAPaths.caCertPath, caCert.certificate),
376
- fs.writeFile(rootCAPaths.caKeyPath, caCert.privateKey, { mode: 0o600 }),
377
- ])
378
- caIsNew = true
379
- debugLog('ssl', `Persisted Root CA at ${rootCAPaths.caCertPath}`, options.verbose)
380
- }
381
- catch (err) {
382
- debugLog('ssl', `Error saving Root CA files: ${err}`, options.verbose)
383
- throw new Error(`Failed to save Root CA files: ${err}`)
384
- }
385
- }
386
- else {
387
- debugLog('ssl', `Reusing existing Root CA from ${rootCAPaths.caCertPath}`, options.verbose)
388
- }
389
-
390
- // Issue the host cert with all SANs from the (possibly reused) CA.
391
- if (options.verbose)
392
- log.info(`Generating host certificate for: ${domains.join(', ')}`)
393
-
394
- const hostCert = await generateCert({
395
- ...hostConfig,
396
- rootCA: {
397
- certificate: caCert.certificate,
398
- privateKey: caCert.privateKey,
399
- },
400
- })
401
-
402
- // Persist host cert + key. Also write a copy of the CA cert at the per-domain
403
- // `caCertPath` for back-compat with anything that still reads from there.
404
- try {
405
- await Promise.all([
406
- fs.writeFile(hostConfig.certPath, hostCert.certificate),
407
- fs.writeFile(hostConfig.keyPath, hostCert.privateKey),
408
- fs.writeFile(hostConfig.caCertPath, caCert.certificate),
409
- ])
410
-
411
- debugLog('ssl', 'Certificate files saved successfully', options.verbose)
412
- }
413
- catch (err) {
414
- debugLog('ssl', `Error saving certificate files: ${err}`, options.verbose)
415
- throw new Error(`Failed to save certificate files: ${err}`)
416
- }
417
-
418
- // The CA is the trust anchor — only it needs to live in the trust store.
419
- // Skip the install step when the CA is already trusted (a freshly minted CA
420
- // is never trusted yet; a reused one might still need a one-time install if
421
- // someone wiped their keychain).
422
- const caTrusted = caIsNew
423
- ? false
424
- : await isCertTrusted(rootCAPaths.caCertPath, { verbose: options.verbose, regenerateUntrustedCerts: true })
425
-
426
- if (caTrusted) {
427
- debugLog('ssl', 'Root CA already trusted, skipping trust store update', options.verbose)
428
- if (options.verbose)
429
- log.success('Root CA is already trusted in system trust store')
430
-
431
- cachedSSLConfig = {
432
- key: hostCert.privateKey,
433
- cert: hostCert.certificate,
434
- ca: caCert.certificate,
435
- }
436
-
437
- if (options.verbose)
438
- log.success(`Certificate generated successfully for ${domains.length} domain${domains.length > 1 ? 's' : ''}`)
439
- return
440
- }
441
-
442
- // Now add to system trust store with a single operation
443
- // This will require only one sudo password prompt
444
- if (options.verbose)
445
- log.info('Adding certificate to system trust store (may require sudo permission)...')
446
-
447
- let isTrusted = false
448
-
449
- // We install only the Root CA — host certs derive their trust from it.
450
- if (process.platform === 'darwin') {
451
- try {
452
- pruneStaleRootCas({ caPath: rootCAPaths.caCertPath, verbose: options.verbose })
453
- const loginKeychain = getMacosLoginKeychainPath()
454
- try {
455
- execSync(`security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k "${loginKeychain}" "${rootCAPaths.caCertPath}"`, { stdio: 'ignore' })
456
- }
457
- catch { /* login keychain optional */ }
458
- execSudoSync(`security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k ${MACOS_SYSTEM_KEYCHAIN} "${rootCAPaths.caCertPath}"`)
459
- if (options.verbose)
460
- log.success('Successfully added Root CA to system trust store')
461
-
462
- isTrusted = true
463
-
464
- // Helper script for manual re-trust if the user ever wipes their keychain.
465
- const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
466
- const scriptContent = `#!/bin/bash
467
- echo "Trusting RPX Root CA"
468
- sudo security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k ${MACOS_SYSTEM_KEYCHAIN} "${rootCAPaths.caCertPath}"
469
- echo "Root CA trusted! Please restart your browser."
470
- echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
471
- `
472
- await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 }) // Make it executable
473
- }
474
- catch (err) {
475
- if (options.verbose)
476
- log.warn(`Could not add Root CA to trust store automatically: ${err}`)
477
-
478
- const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
479
- const scriptContent = `#!/bin/bash
480
- echo "Trusting RPX Root CA"
481
- sudo security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k ${MACOS_SYSTEM_KEYCHAIN} "${rootCAPaths.caCertPath}"
482
- echo "Root CA trusted! Please restart your browser."
483
- echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
484
- `
485
- await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 })
486
- if (options.verbose) {
487
- log.info(`Created a trust helper script at: ${scriptPath}`)
488
- log.info(`If you're still having certificate issues, run: sh ${scriptPath}`)
489
- }
490
- }
491
- }
492
- else if (process.platform === 'linux') {
493
- try {
494
- // On Linux, we need to copy to the trusted certificates directory
495
- const { exec } = await import('node:child_process')
496
- const certDir = '/usr/local/share/ca-certificates/rpx'
497
-
498
- // Create a more reliable trust script
499
- const trustScript = `
500
- mkdir -p "${certDir}" 2>/dev/null || true
501
- cp "${rootCAPaths.caCertPath}" "${certDir}/"
502
- update-ca-certificates
503
- echo "RPX Root CA installed. Please restart your browser."
504
- `
505
- // Use a temp file for the script
506
- const tmpScript = join(os.tmpdir(), `rpx-trust-${Date.now()}.sh`)
507
- await fs.writeFile(tmpScript, trustScript, { mode: 0o755 })
508
-
509
- // Run with one sudo prompt
510
- await new Promise((resolve) => {
511
- exec(`sudo bash "${tmpScript}"`, (error) => {
512
- if (error) {
513
- if (options.verbose)
514
- log.warn(`Could not trust certificates: ${error}`)
515
- resolve(false)
516
- }
517
- else {
518
- if (options.verbose)
519
- log.success('Successfully added certificates to system trust store')
520
- resolve(true)
521
- }
522
- })
523
- })
524
-
525
- // Clean up
526
- await fs.unlink(tmpScript).catch(() => {})
527
-
528
- isTrusted = true
529
- }
530
- catch (error) {
531
- if (options.verbose)
532
- log.warn(`Failed to trust certificates: ${error}`)
533
- }
534
- }
535
- else if (process.platform === 'win32') {
536
- // Windows certificate trust
537
- try {
538
- // Windows is different - use a PowerShell approach
539
- const winScript = `
540
- $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("${rootCAPaths.caCertPath.replace(/\//g, '\\')}")
541
- $store = New-Object System.Security.Cryptography.X509Certificates.X509Store("ROOT", "LocalMachine")
542
- $store.Open("ReadWrite")
543
- $store.Add($cert)
544
- $store.Close()
545
- Write-Host "Root CA trusted successfully!"
546
- `
547
- const psPath = join(os.tmpdir(), 'rpx-trust.ps1')
548
- await fs.writeFile(psPath, winScript)
549
-
550
- execSync(`powershell -ExecutionPolicy Bypass -File "${psPath}"`)
551
- if (options.verbose)
552
- log.success('Successfully added certificate to Windows trust store')
553
- isTrusted = true
554
- }
555
- catch (error) {
556
- if (options.verbose)
557
- log.warn(`Could not trust certificate: ${error}`)
558
- }
559
- }
560
- else {
561
- // Use the built-in trust mechanism for other platforms
562
- try {
563
- await addCertToSystemTrustStoreAndSaveCert(hostCert, caCert.certificate, hostConfig)
564
- // Assume this worked for now
565
- isTrusted = true
566
- }
567
- catch (err) {
568
- if (options.verbose)
569
- log.warn(`Could not add certificate to trust store: ${err}`)
570
- }
571
- }
572
-
573
- // Cache the SSL config for reuse
574
- cachedSSLConfig = {
575
- key: hostCert.privateKey,
576
- cert: hostCert.certificate,
577
- ca: caCert.certificate,
578
- }
579
-
580
- if (options.verbose)
581
- log.success(`Certificate generated successfully for ${domains.length} domain${domains.length > 1 ? 's' : ''}`)
582
-
583
- // Show Chrome bypass tip if trust might have issues
584
- if (!isTrusted && options.verbose) {
585
- log.warn('If you see certificate warnings in Chrome/Arc, type "thisisunsafe" on the warning page')
586
- log.warn('This will bypass the warning and you should only need to do it once')
587
- }
588
- }
589
-
590
- export function getSSLConfig(): { key: string, cert: string, ca?: string } | null {
591
- return cachedSSLConfig
592
- }
593
-
594
- /** Clear in-process TLS cache so the next generate/load picks up new files on disk. */
595
- export function clearSslConfigCache(): void {
596
- cachedSSLConfig = null
597
- }
598
-
599
- // needs to accept the options
600
- export async function checkExistingCertificates(options?: ProxyOptions): Promise<SSLConfig | null> {
601
- if (!options)
602
- return null
603
-
604
- if (cachedSSLConfig)
605
- return cachedSSLConfig
606
-
607
- // Use httpsConfig to get the path configuration
608
- const sslConfig = httpsConfig(options)
609
-
610
- try {
611
- // Check if certificate files exist
612
- const [keyExists, certExists, caExists] = await Promise.all([
613
- fs.access(sslConfig.keyPath).then(() => true).catch(() => false),
614
- fs.access(sslConfig.certPath).then(() => true).catch(() => false),
615
- sslConfig.caCertPath ? fs.access(sslConfig.caCertPath).then(() => true).catch(() => false) : Promise.resolve(false),
616
- ])
617
-
618
- if (!keyExists || !certExists) {
619
- debugLog('ssl', `Certificate files don't exist: key=${keyExists}, cert=${certExists}, paths: ${sslConfig.keyPath}, ${sslConfig.certPath}`, options.verbose)
620
- return null
621
- }
622
-
623
- // Check if certificate is trusted
624
- // But only if regenerateUntrustedCerts is enabled (default is true)
625
- const hasFlag = 'regenerateUntrustedCerts' in options
626
- const flagValue = options.regenerateUntrustedCerts
627
- const shouldCheckTrust = hasFlag ? flagValue !== false : true
628
- debugLog('ssl', `Trust check: hasFlag=${hasFlag}, flagValue=${flagValue}, shouldCheckTrust=${shouldCheckTrust}`, options.verbose)
629
- // Trust anchor is the shared Root CA — host certs are not installed individually.
630
- const sslDir = sslConfig.basePath || join(homedir(), '.stacks', 'ssl')
631
- const rootCAPaths = getRootCAPaths(sslDir)
632
- const caIsTrusted = shouldCheckTrust
633
- ? await isCertTrusted(rootCAPaths.caCertPath, options)
634
- : true
635
-
636
- if (!caIsTrusted) {
637
- debugLog('ssl', 'Root CA exists but is not trusted, will regenerate', options.verbose)
638
- // Don't attempt to trust here - let generateCertificate handle it in one place
639
- // This avoids multiple sudo prompts
640
- return null
641
- }
642
-
643
- // Load the certificates
644
- const [key, cert, ca] = await Promise.all([
645
- fs.readFile(sslConfig.keyPath, 'utf8'),
646
- fs.readFile(sslConfig.certPath, 'utf8'),
647
- caExists && sslConfig.caCertPath ? fs.readFile(sslConfig.caCertPath, 'utf8') : Promise.resolve(undefined),
648
- ])
649
-
650
- // Validate root CA PEM content if present
651
- if (ca && !ca.includes('-----BEGIN CERTIFICATE-----')) {
652
- debugLog('ssl', 'Invalid root CA certificate content, will regenerate', options.verbose)
653
- return null
654
- }
655
-
656
- // For multi-proxy configs, verify the cert covers ALL requested domains
657
- if (isMultiProxyOptions(options)) {
658
- try {
659
- const { X509Certificate } = await import('node:crypto')
660
- const x509 = new X509Certificate(cert)
661
- const san = x509.subjectAltName || ''
662
- const requiredDomains = options.proxies.map(p => p.to)
663
- const missingDomains = requiredDomains.filter(d => !san.includes(`DNS:${d}`))
664
- if (missingDomains.length > 0) {
665
- debugLog('ssl', `Certificate missing SANs for: ${missingDomains.join(', ')}, will regenerate`, options.verbose)
666
- return null
667
- }
668
- }
669
- catch (err) {
670
- debugLog('ssl', `Could not verify cert SANs: ${err}`, options.verbose)
671
- }
672
- }
673
-
674
- debugLog('ssl', 'Successfully loaded existing certificates', options.verbose)
675
-
676
- // Cache the result
677
- cachedSSLConfig = { key, cert, ca }
678
- return cachedSSLConfig
679
- }
680
- catch (err) {
681
- debugLog('ssl', `Error checking existing certificates: ${err}`, options.verbose)
682
- return null
683
- }
684
- }
685
-
686
- export function httpsConfig(options: ProxyOption | ProxyOptions, verbose?: boolean): TlsConfig {
687
- const primaryDomain = getPrimaryDomain(options)
688
- debugLog('ssl', `Primary domain: ${primaryDomain}`, verbose)
689
-
690
- // Generate paths based on domain if not explicitly provided
691
- const defaultPaths = generateSSLPaths(options)
692
- const defaultBasePath = join(homedir(), '.stacks', 'ssl')
693
-
694
- // If HTTPS paths are explicitly provided, use those
695
- if (typeof options.https === 'object') {
696
- // Use provided basePath if non-empty, otherwise use default
697
- const basePath = options.https.basePath && options.https.basePath.trim() !== ''
698
- ? options.https.basePath
699
- : defaultBasePath
700
-
701
- const config: TlsConfig = {
702
- domain: primaryDomain,
703
- hostCertCN: primaryDomain,
704
- basePath,
705
- caCertPath: options.https.caCertPath || defaultPaths.caCertPath,
706
- certPath: options.https.certPath || defaultPaths.certPath,
707
- keyPath: options.https.keyPath || defaultPaths.keyPath,
708
- altNameIPs: ['127.0.0.1', '::1'],
709
- altNameURIs: [],
710
- commonName: options.https.commonName || primaryDomain,
711
- organizationName: options.https.organizationName || 'Local Development',
712
- countryName: options.https.countryName || 'US',
713
- stateName: options.https.stateName || 'California',
714
- localityName: options.https.localityName || 'Playa Vista',
715
- validityDays: options.https.validityDays || 825,
716
- verbose: verbose || false,
717
- subjectAltNames: Array.from(getAllDomains(options)).map(domain => ({
718
- type: 2,
719
- value: domain,
720
- })),
721
- }
722
-
723
- // Add optional properties if they exist and are valid
724
- if (isValidRootCA(options.https.rootCA)) {
725
- config.rootCA = options.https.rootCA
726
- }
727
-
728
- return config
729
- }
730
-
731
- // Return default configuration
732
- return {
733
- domain: primaryDomain,
734
- hostCertCN: primaryDomain,
735
- basePath: defaultBasePath,
736
- ...defaultPaths,
737
- altNameIPs: ['127.0.0.1', '::1'],
738
- altNameURIs: [],
739
- commonName: primaryDomain,
740
- organizationName: 'Local Development',
741
- countryName: 'US',
742
- stateName: 'California',
743
- localityName: 'Playa Vista',
744
- validityDays: 825,
745
- verbose: verbose || false,
746
- subjectAltNames: Array.from(getAllDomains(options)).map(domain => ({
747
- type: 2,
748
- value: domain,
749
- })),
750
- }
751
- }
752
-
753
- /**
754
- * Clean up SSL certificates for a specific domain
755
- */
756
- export async function cleanupCertificates(domain: string, verbose?: boolean): Promise<void> {
757
- const certPaths = generateSSLPaths({ to: domain, verbose })
758
-
759
- // Define all possible certificate files
760
- const filesToDelete = [
761
- certPaths.caCertPath,
762
- certPaths.certPath,
763
- certPaths.keyPath,
764
- ]
765
-
766
- debugLog('certificates', `Attempting to clean up relating certificates`, verbose)
767
-
768
- // Delete all files concurrently
769
- await Promise.all(filesToDelete.map(file => safeDeleteFile(file, verbose)))
770
- }
771
-
772
- /**
773
- * Checks if a certificate is trusted by the system (macOS only for now)
774
- * If options.regenerateUntrustedCerts is false, always returns true (skips trust check)
775
- */
776
- export async function isCertTrusted(
777
- certPath: string,
778
- options?: { verbose?: boolean, regenerateUntrustedCerts?: boolean, serverName?: string },
779
- ): Promise<boolean> {
780
- try {
781
- debugLog('ssl', `Checking if certificate is trusted: ${certPath}`, options?.verbose)
782
-
783
- // Different check methods per platform
784
- if (process.platform === 'darwin') {
785
- if (options?.serverName)
786
- return isRootCaTrustedForSsl(certPath, options.serverName, { verbose: options?.verbose })
787
- return isRootCaFingerprintInKeychains(certPath, { verbose: options?.verbose })
788
- }
789
- else if (process.platform === 'win32') {
790
- // On Windows, use PowerShell to check the certificate store
791
- try {
792
- // Get certificate subject from file
793
- const certSubject = execSync(`openssl x509 -noout -subject -in "${certPath}"`).toString().trim()
794
- const subjectName = certSubject.split('=').slice(1).join('=').trim() || ''
795
-
796
- if (!subjectName) {
797
- debugLog('ssl', 'Could not extract certificate subject', options?.verbose)
798
- return false
799
- }
800
-
801
- // Check if the certificate exists in the trusted root store
802
- const powershellCmd = `powershell -Command "Get-ChildItem -Path Cert:\\LocalMachine\\Root | Where-Object { $_.Subject -like '*${subjectName}*' } | Select-Object Subject"`
803
- const storeOutput = execSync(powershellCmd).toString()
804
-
805
- if (storeOutput.includes(subjectName)) {
806
- debugLog('ssl', 'Certificate found in trusted root store', options?.verbose)
807
- return true
808
- }
809
-
810
- debugLog('ssl', 'Certificate not found in trusted root store', options?.verbose)
811
- return false
812
- }
813
- catch (error) {
814
- debugLog('ssl', `Error checking certificate trust on Windows: ${error}`, options?.verbose)
815
- return false
816
- }
817
- }
818
- else if (process.platform === 'linux') {
819
- // On Linux, check using OpenSSL against the system trust store
820
- try {
821
- // This is a simplified check and may need to be adjusted per distribution
822
- const certFingerprint = execSync(`openssl x509 -noout -fingerprint -sha256 -in "${certPath}"`).toString().trim()
823
- const fingerprintValue = certFingerprint.split('=')[1]?.trim() || ''
824
-
825
- // Different distros store certs in different locations
826
- const trustStores = [
827
- '/etc/ssl/certs', // Debian/Ubuntu
828
- '/etc/pki/tls/certs', // RedHat/CentOS
829
- ]
830
-
831
- for (const store of trustStores) {
832
- try {
833
- const storeOutput = execSync(`find ${store} -type f -exec openssl x509 -noout -fingerprint -sha256 -in {} \\; 2>/dev/null | grep "${fingerprintValue}"`).toString()
834
-
835
- if (storeOutput.includes(fingerprintValue)) {
836
- debugLog('ssl', `Certificate fingerprint found in ${store}`, options?.verbose)
837
- return true
838
- }
839
- }
840
- catch {
841
- // Ignore errors searching specific stores
842
- }
843
- }
844
-
845
- debugLog('ssl', 'Certificate not found in system trust stores', options?.verbose)
846
- return false
847
- }
848
- catch (error) {
849
- debugLog('ssl', `Error checking certificate trust on Linux: ${error}`, options?.verbose)
850
- return false
851
- }
852
- }
853
-
854
- // Default to false for unsupported platforms
855
- debugLog('ssl', `Platform ${process.platform} not supported for certificate trust check`, options?.verbose)
856
- return false
857
- }
858
- catch (err) {
859
- debugLog('ssl', `Error checking if certificate is trusted: ${err}`, options?.verbose)
860
- return false
861
- }
862
- }