@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/dist/bin/cli.js +1 -1
- package/dist/{chunk-dz3837t8.js → chunk-8yenn1z8.js} +1 -1
- package/dist/chunk-cvt0dqrv.js +49 -0
- package/dist/{chunk-61re8msk.js → chunk-cy653fq8.js} +1 -1
- package/dist/chunk-grcvjvzg.js +124 -0
- package/dist/{chunk-gbny098p.js → chunk-sqn04kae.js} +1 -1
- package/dist/chunk-wcerh8e8.js +1 -0
- package/dist/https.d.ts +0 -13
- package/dist/process-manager.d.ts +1 -0
- package/dist/src/index.js +1 -1
- package/dist/start.d.ts +3 -0
- package/dist/utils.d.ts +11 -1
- package/package.json +3 -11
- package/src/colors.ts +13 -0
- package/src/config.ts +45 -0
- package/src/dns.ts +399 -0
- package/src/hosts.ts +257 -0
- package/src/https.ts +780 -0
- package/src/index.ts +33 -0
- package/src/logger.ts +19 -0
- package/src/port-manager.ts +183 -0
- package/src/process-manager.ts +164 -0
- package/src/start.ts +1357 -0
- package/src/types.ts +93 -0
- package/src/utils.ts +156 -0
- package/dist/chunk-8mnzvjyr.js +0 -123
- package/dist/chunk-94pvxvt5.js +0 -1
- package/dist/chunk-g5db14m7.js +0 -19
- /package/dist/{chunk-3y886wa5.js → chunk-hj5q1vd6.js} +0 -0
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
|
+
}
|