@stacksjs/rpx 0.11.19 → 0.11.21

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