@stacksjs/rpx 0.11.3 → 0.11.5

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