@stacksjs/rpx 0.11.7 → 0.11.9
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 +144 -142
- package/dist/cert-inspect.d.ts +15 -0
- package/dist/daemon-runner.d.ts +3 -0
- package/dist/https.d.ts +32 -2
- package/dist/index.d.ts +23 -0
- package/dist/index.js +81 -78
- package/dist/macos-trust.d.ts +40 -0
- package/package.json +1 -1
- package/src/cert-inspect.ts +69 -0
- package/src/daemon-runner.ts +15 -2
- package/src/daemon.ts +29 -7
- package/src/https.ts +94 -53
- package/src/index.ts +25 -0
- package/src/macos-trust.ts +175 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare function getMacosLoginKeychainPath(): string;
|
|
2
|
+
export declare function getMacosTrustKeychains(): string[];
|
|
3
|
+
export declare function listCertSha256HashesByCommonName(keychain: string, commonName?: string): string[];
|
|
4
|
+
/**
|
|
5
|
+
* Remove older rpx Root CA copies from keychains, keeping only the fingerprint
|
|
6
|
+
* that matches the on-disk `caPath` file.
|
|
7
|
+
*/
|
|
8
|
+
export declare function pruneStaleRootCas(options: PruneStaleRootCasOptions): void;
|
|
9
|
+
/**
|
|
10
|
+
* True when the Root CA is trusted for SSL to `serverName` (macOS verify-cert).
|
|
11
|
+
* On other platforms, falls back to fingerprint presence in trust stores.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isRootCaTrustedForSsl(caPath: string, serverName: string, options?: { verbose?: boolean }): boolean;
|
|
14
|
+
export declare function isRootCaFingerprintInKeychains(caPath: string, options?: { verbose?: boolean }): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Install the Root CA into login + system keychains with SSL/basic policies,
|
|
17
|
+
* pruning stale copies first. Returns true when SSL verification succeeds or
|
|
18
|
+
* the cert fingerprint is present in a keychain.
|
|
19
|
+
*/
|
|
20
|
+
export declare function trustRootCaForBrowsers(caPath: string, options: TrustRootCaForBrowsersOptions): boolean;
|
|
21
|
+
/** Chrome/Edge need SSL + basic trust policies — plain trustRoot often leaves "trust settings: 0". */
|
|
22
|
+
export declare const MACOS_CA_TRUST_FLAGS: '-d -r trustRoot -p ssl -p basic';
|
|
23
|
+
export declare const MACOS_SYSTEM_KEYCHAIN: '/Library/Keychains/System.keychain';
|
|
24
|
+
/** Default CN label for the shared rpx Root CA in macOS keychain listings. */
|
|
25
|
+
export declare const RPX_ROOT_CA_COMMON_NAME: 'rpx.localhost';
|
|
26
|
+
export declare interface ListCertsByCommonNameOptions {
|
|
27
|
+
keychain: string
|
|
28
|
+
commonName?: string
|
|
29
|
+
}
|
|
30
|
+
export declare interface PruneStaleRootCasOptions {
|
|
31
|
+
caPath: string
|
|
32
|
+
commonName?: string
|
|
33
|
+
keychains?: string[]
|
|
34
|
+
verbose?: boolean
|
|
35
|
+
}
|
|
36
|
+
export declare interface TrustRootCaForBrowsersOptions {
|
|
37
|
+
serverName: string
|
|
38
|
+
commonName?: string
|
|
39
|
+
verbose?: boolean
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize openssl / security fingerprint output to uppercase hex without separators.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeSha256Fingerprint(raw: string): string {
|
|
7
|
+
const value = raw.includes('=') ? raw.split('=').pop()! : raw
|
|
8
|
+
return value.replace(/SHA-256\s+hash:\s*/gi, '').replace(/:/g, '').trim().toUpperCase()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readCertSha256Fingerprint(certPath: string): string | null {
|
|
12
|
+
try {
|
|
13
|
+
const out = execSync(`openssl x509 -noout -fingerprint -sha256 -in "${certPath}"`, { encoding: 'utf8' })
|
|
14
|
+
return normalizeSha256Fingerprint(out)
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readCertCommonName(certPath: string): string | null {
|
|
22
|
+
try {
|
|
23
|
+
const subject = execSync(`openssl x509 -in "${certPath}" -noout -subject -nameopt RFC2253`, { encoding: 'utf8' })
|
|
24
|
+
const match = subject.match(/CN=([^,/]+)/)
|
|
25
|
+
return match?.[1]?.trim() ?? null
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function certIncludesSanHostnames(certPath: string, hostnames: string[]): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const text = execSync(`openssl x509 -in "${certPath}" -noout -text`, { encoding: 'utf8' })
|
|
35
|
+
return hostnames.every(host => text.includes(`DNS:${host}`))
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True when :443 (or `port`) presents a chain trusted by `caPath` for `domain`.
|
|
44
|
+
*/
|
|
45
|
+
export function verifyHttpsChain(domain: string, caPath: string, port = 443): boolean {
|
|
46
|
+
try {
|
|
47
|
+
const out = execSync(
|
|
48
|
+
`echo | openssl s_client -connect ${domain}:${port} -servername ${domain} -CAfile "${caPath}" 2>/dev/null | grep "Verify return code"`,
|
|
49
|
+
{ encoding: 'utf8', timeout: 4000 },
|
|
50
|
+
)
|
|
51
|
+
return out.includes(': 0 (ok)')
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse `security find-certificate -Z` listing lines into SHA-256 hashes.
|
|
60
|
+
*/
|
|
61
|
+
export function parseSha256HashesFromSecurityListing(listing: string): string[] {
|
|
62
|
+
const hashes: string[] = []
|
|
63
|
+
for (const line of listing.split('\n')) {
|
|
64
|
+
const match = line.match(/SHA-256 hash:\s*([A-F0-9]+)/i)
|
|
65
|
+
if (match)
|
|
66
|
+
hashes.push(match[1]!.toUpperCase())
|
|
67
|
+
}
|
|
68
|
+
return hashes
|
|
69
|
+
}
|
package/src/daemon-runner.ts
CHANGED
|
@@ -42,6 +42,16 @@ export interface DaemonRunnerOptions {
|
|
|
42
42
|
detached?: boolean
|
|
43
43
|
/** Override the daemon spawn command (tests). */
|
|
44
44
|
spawnCommand?: string[]
|
|
45
|
+
/** Passed through to `ensureDaemonRunning` (default 5000ms). */
|
|
46
|
+
startupTimeoutMs?: number
|
|
47
|
+
/** Extra env for the daemon child (e.g. `SUDO_PASSWORD`). */
|
|
48
|
+
spawnEnv?: Record<string, string>
|
|
49
|
+
/**
|
|
50
|
+
* When true, registry entries omit `pid` so they persist until
|
|
51
|
+
* `rpx unregister` — avoids PID-GC dropping routes when the registering
|
|
52
|
+
* process is short-lived (e.g. a CLI wrapper).
|
|
53
|
+
*/
|
|
54
|
+
persistent?: boolean
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/**
|
|
@@ -78,14 +88,15 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
|
|
|
78
88
|
return { ...p, id }
|
|
79
89
|
})
|
|
80
90
|
|
|
91
|
+
const createdAt = new Date().toISOString()
|
|
81
92
|
for (const p of resolved) {
|
|
82
93
|
await writeEntry({
|
|
83
94
|
id: p.id,
|
|
84
95
|
from: p.from,
|
|
85
96
|
to: p.to,
|
|
86
|
-
pid: process.pid,
|
|
97
|
+
pid: opts.persistent ? undefined : process.pid,
|
|
87
98
|
cwd: process.cwd(),
|
|
88
|
-
createdAt
|
|
99
|
+
createdAt,
|
|
89
100
|
cleanUrls: p.cleanUrls,
|
|
90
101
|
changeOrigin: p.changeOrigin,
|
|
91
102
|
pathRewrites: p.pathRewrites,
|
|
@@ -96,6 +107,8 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
|
|
|
96
107
|
rpxDir: opts.rpxDir,
|
|
97
108
|
verbose,
|
|
98
109
|
spawnCommand: opts.spawnCommand,
|
|
110
|
+
startupTimeoutMs: opts.startupTimeoutMs,
|
|
111
|
+
spawnEnv: opts.spawnEnv,
|
|
99
112
|
})
|
|
100
113
|
|
|
101
114
|
for (const p of resolved)
|
package/src/daemon.ts
CHANGED
|
@@ -156,16 +156,38 @@ function entryToRoute(entry: RegistryEntry): ProxyRoute {
|
|
|
156
156
|
* Bootstrap the daemon's TLS material. Reuses the persisted Root CA and any
|
|
157
157
|
* existing trusted host cert; mints fresh ones if none exist.
|
|
158
158
|
*
|
|
159
|
-
* The host cert
|
|
160
|
-
* `
|
|
161
|
-
*
|
|
159
|
+
* The host cert SAN list includes every hostname in the registry (e.g.
|
|
160
|
+
* `postline.localhost`, `api.postline.localhost`). Chrome does not treat
|
|
161
|
+
* `*.localhost` as matching `<app>.localhost`, so those names must be explicit.
|
|
162
162
|
*/
|
|
163
|
-
|
|
163
|
+
function pickPrimaryRegistryHost(hosts: string[]): string {
|
|
164
|
+
const appHost = hosts.find(h => !/^api\./.test(h) && !/^docs\./.test(h) && !/^dashboard\./.test(h))
|
|
165
|
+
return appHost ?? hosts[0] ?? 'rpx.localhost'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<SSLConfig> {
|
|
169
|
+
const entries = await readAll(registryDir, opts.verbose)
|
|
170
|
+
const registryHosts = [...new Set(entries.map(e => e.to))]
|
|
171
|
+
const primary = pickPrimaryRegistryHost(registryHosts)
|
|
172
|
+
const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
|
|
173
|
+
|
|
174
|
+
const sslDir = path.join(homedir(), '.stacks', 'ssl')
|
|
175
|
+
const sharedCert = path.join(sslDir, 'rpx.localhost.crt')
|
|
176
|
+
|
|
164
177
|
const proxyOpts: ProxyOptions = {
|
|
165
|
-
https: opts.https
|
|
166
|
-
|
|
178
|
+
https: typeof opts.https === 'object'
|
|
179
|
+
? { ...opts.https, certPath: sharedCert, keyPath: path.join(sslDir, 'rpx.localhost.key'), commonName: primary }
|
|
180
|
+
: {
|
|
181
|
+
certPath: sharedCert,
|
|
182
|
+
keyPath: path.join(sslDir, 'rpx.localhost.key'),
|
|
183
|
+
caCertPath: path.join(sslDir, 'rpx.localhost.ca.crt'),
|
|
184
|
+
commonName: primary,
|
|
185
|
+
},
|
|
167
186
|
verbose: opts.verbose,
|
|
168
187
|
regenerateUntrustedCerts: true,
|
|
188
|
+
...(hostnames.length > 1
|
|
189
|
+
? { proxies: hostnames.map(to => ({ from: 'localhost:1', to })) }
|
|
190
|
+
: { to: primary, from: 'localhost:1' }),
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
let sslConfig = await checkExistingCertificates(proxyOpts)
|
|
@@ -216,7 +238,7 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
216
238
|
})
|
|
217
239
|
rebuild(await readAll(registryDir, verbose))
|
|
218
240
|
|
|
219
|
-
const sslConfig = await bootstrapTls(opts)
|
|
241
|
+
const sslConfig = await bootstrapTls(opts, registryDir)
|
|
220
242
|
|
|
221
243
|
const httpsServer = Bun.serve({
|
|
222
244
|
port: httpsPort,
|
package/src/https.ts
CHANGED
|
@@ -9,8 +9,39 @@ import * as process from 'node:process'
|
|
|
9
9
|
import { addCertToSystemTrustStoreAndSaveCert, createRootCA, generateCertificate as generateCert } from '@stacksjs/tlsx'
|
|
10
10
|
import { log } from './logger'
|
|
11
11
|
import { config } from './config'
|
|
12
|
+
import {
|
|
13
|
+
MACOS_CA_TRUST_FLAGS,
|
|
14
|
+
MACOS_SYSTEM_KEYCHAIN,
|
|
15
|
+
getMacosLoginKeychainPath,
|
|
16
|
+
isRootCaFingerprintInKeychains,
|
|
17
|
+
isRootCaTrustedForSsl,
|
|
18
|
+
pruneStaleRootCas,
|
|
19
|
+
trustRootCaForBrowsers,
|
|
20
|
+
} from './macos-trust'
|
|
12
21
|
import { debugLog, execSudoSync, getPrimaryDomain, isMultiProxyConfig, isMultiProxyOptions, isSingleProxyOptions, isValidRootCA, safeDeleteFile } from './utils'
|
|
13
22
|
|
|
23
|
+
export {
|
|
24
|
+
MACOS_CA_TRUST_FLAGS,
|
|
25
|
+
MACOS_SYSTEM_KEYCHAIN,
|
|
26
|
+
RPX_ROOT_CA_COMMON_NAME,
|
|
27
|
+
getMacosLoginKeychainPath,
|
|
28
|
+
getMacosTrustKeychains,
|
|
29
|
+
isRootCaFingerprintInKeychains,
|
|
30
|
+
isRootCaTrustedForSsl,
|
|
31
|
+
listCertSha256HashesByCommonName,
|
|
32
|
+
pruneStaleRootCas,
|
|
33
|
+
trustRootCaForBrowsers,
|
|
34
|
+
} from './macos-trust'
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
certIncludesSanHostnames,
|
|
38
|
+
normalizeSha256Fingerprint,
|
|
39
|
+
parseSha256HashesFromSecurityListing,
|
|
40
|
+
readCertCommonName,
|
|
41
|
+
readCertSha256Fingerprint,
|
|
42
|
+
verifyHttpsChain,
|
|
43
|
+
} from './cert-inspect'
|
|
44
|
+
|
|
14
45
|
let cachedSSLConfig: { key: string, cert: string, ca?: string } | null = null
|
|
15
46
|
|
|
16
47
|
// Canonical filenames for the shared Root CA. The CA is a singleton across all
|
|
@@ -34,6 +65,21 @@ export function getRootCAPaths(basePath: string): RootCAPaths {
|
|
|
34
65
|
}
|
|
35
66
|
}
|
|
36
67
|
|
|
68
|
+
/** Paths for the shared multi-host daemon cert under `~/.stacks/ssl`. */
|
|
69
|
+
export function getSharedDaemonCertPaths(sslDir: string): {
|
|
70
|
+
certPath: string
|
|
71
|
+
keyPath: string
|
|
72
|
+
caCertPath: string
|
|
73
|
+
rootCA: RootCAPaths
|
|
74
|
+
} {
|
|
75
|
+
return {
|
|
76
|
+
certPath: join(sslDir, 'rpx.localhost.crt'),
|
|
77
|
+
keyPath: join(sslDir, 'rpx.localhost.key'),
|
|
78
|
+
caCertPath: join(sslDir, 'rpx.localhost.ca.crt'),
|
|
79
|
+
rootCA: getRootCAPaths(sslDir),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
37
83
|
/**
|
|
38
84
|
* Load a previously-persisted Root CA (cert + private key). Returns `null` if
|
|
39
85
|
* either file is missing or unreadable, signalling that a fresh CA needs to be
|
|
@@ -230,9 +276,12 @@ export async function loadSSLConfig(options: ProxyOption): Promise<SSLConfig | n
|
|
|
230
276
|
/**
|
|
231
277
|
* Force trust a certificate - exposing for direct use
|
|
232
278
|
*/
|
|
233
|
-
export async function forceTrustCertificate(
|
|
279
|
+
export async function forceTrustCertificate(
|
|
280
|
+
certPath: string,
|
|
281
|
+
options?: { serverName?: string, verbose?: boolean },
|
|
282
|
+
): Promise<boolean> {
|
|
234
283
|
if (process.platform === 'darwin')
|
|
235
|
-
return forceTrustCertificateMacOS(certPath)
|
|
284
|
+
return forceTrustCertificateMacOS(certPath, options)
|
|
236
285
|
|
|
237
286
|
if (process.platform === 'linux') {
|
|
238
287
|
try {
|
|
@@ -270,28 +319,24 @@ export async function forceTrustCertificate(certPath: string): Promise<boolean>
|
|
|
270
319
|
* Force trust a certificate on macOS using direct security command
|
|
271
320
|
* This function first checks if the certificate is already trusted to avoid unnecessary sudo prompts
|
|
272
321
|
*/
|
|
273
|
-
async function forceTrustCertificateMacOS(
|
|
322
|
+
async function forceTrustCertificateMacOS(
|
|
323
|
+
certPath: string,
|
|
324
|
+
options?: { serverName?: string, verbose?: boolean },
|
|
325
|
+
): Promise<boolean> {
|
|
274
326
|
if (process.platform !== 'darwin')
|
|
275
327
|
return false
|
|
276
328
|
|
|
329
|
+
const serverName = options?.serverName ?? 'rpx.localhost'
|
|
330
|
+
const verbose = options?.verbose ?? false
|
|
331
|
+
|
|
277
332
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (alreadyTrusted) {
|
|
281
|
-
debugLog('ssl', 'Certificate is already trusted, skipping trust operation', false)
|
|
333
|
+
if (isRootCaTrustedForSsl(certPath, serverName, { verbose })) {
|
|
334
|
+
debugLog('ssl', 'Root CA already trusted for SSL, skipping trust operation', verbose)
|
|
282
335
|
return true
|
|
283
336
|
}
|
|
284
337
|
|
|
285
|
-
debugLog('ssl', 'Trusting
|
|
286
|
-
|
|
287
|
-
// Use execSudoSync which handles SUDO_PASSWORD from env
|
|
288
|
-
try {
|
|
289
|
-
execSudoSync(`security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`)
|
|
290
|
-
return true
|
|
291
|
-
}
|
|
292
|
-
catch {
|
|
293
|
-
return false
|
|
294
|
-
}
|
|
338
|
+
debugLog('ssl', 'Trusting Root CA for browsers (login + system keychains)', verbose)
|
|
339
|
+
return trustRootCaForBrowsers(certPath, { serverName, verbose })
|
|
295
340
|
}
|
|
296
341
|
catch {
|
|
297
342
|
return false
|
|
@@ -299,10 +344,11 @@ async function forceTrustCertificateMacOS(certPath: string): Promise<boolean> {
|
|
|
299
344
|
}
|
|
300
345
|
|
|
301
346
|
export async function generateCertificate(options: ProxyOptions): Promise<void> {
|
|
302
|
-
if (cachedSSLConfig) {
|
|
347
|
+
if (cachedSSLConfig && !(options as { forceRegenerate?: boolean }).forceRegenerate) {
|
|
303
348
|
debugLog('ssl', 'Using cached SSL configuration', options.verbose)
|
|
304
349
|
return
|
|
305
350
|
}
|
|
351
|
+
clearSslConfigCache()
|
|
306
352
|
|
|
307
353
|
// Get all unique domains from the configuration
|
|
308
354
|
const domains: string[] = isMultiProxyOptions(options)
|
|
@@ -403,7 +449,13 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
|
|
|
403
449
|
// We install only the Root CA — host certs derive their trust from it.
|
|
404
450
|
if (process.platform === 'darwin') {
|
|
405
451
|
try {
|
|
406
|
-
|
|
452
|
+
pruneStaleRootCas({ caPath: rootCAPaths.caCertPath, verbose: options.verbose })
|
|
453
|
+
const loginKeychain = getMacosLoginKeychainPath()
|
|
454
|
+
try {
|
|
455
|
+
execSync(`security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k "${loginKeychain}" "${rootCAPaths.caCertPath}"`, { stdio: 'ignore' })
|
|
456
|
+
}
|
|
457
|
+
catch { /* login keychain optional */ }
|
|
458
|
+
execSudoSync(`security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k ${MACOS_SYSTEM_KEYCHAIN} "${rootCAPaths.caCertPath}"`)
|
|
407
459
|
if (options.verbose)
|
|
408
460
|
log.success('Successfully added Root CA to system trust store')
|
|
409
461
|
|
|
@@ -413,7 +465,7 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
|
|
|
413
465
|
const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
|
|
414
466
|
const scriptContent = `#!/bin/bash
|
|
415
467
|
echo "Trusting RPX Root CA"
|
|
416
|
-
sudo security add-trusted-cert
|
|
468
|
+
sudo security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k ${MACOS_SYSTEM_KEYCHAIN} "${rootCAPaths.caCertPath}"
|
|
417
469
|
echo "Root CA trusted! Please restart your browser."
|
|
418
470
|
echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
|
|
419
471
|
`
|
|
@@ -426,7 +478,7 @@ echo "If you still see certificate warnings, type 'thisisunsafe' on the warning
|
|
|
426
478
|
const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
|
|
427
479
|
const scriptContent = `#!/bin/bash
|
|
428
480
|
echo "Trusting RPX Root CA"
|
|
429
|
-
sudo security add-trusted-cert
|
|
481
|
+
sudo security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k ${MACOS_SYSTEM_KEYCHAIN} "${rootCAPaths.caCertPath}"
|
|
430
482
|
echo "Root CA trusted! Please restart your browser."
|
|
431
483
|
echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
|
|
432
484
|
`
|
|
@@ -539,6 +591,11 @@ export function getSSLConfig(): { key: string, cert: string, ca?: string } | nul
|
|
|
539
591
|
return cachedSSLConfig
|
|
540
592
|
}
|
|
541
593
|
|
|
594
|
+
/** Clear in-process TLS cache so the next generate/load picks up new files on disk. */
|
|
595
|
+
export function clearSslConfigCache(): void {
|
|
596
|
+
cachedSSLConfig = null
|
|
597
|
+
}
|
|
598
|
+
|
|
542
599
|
// needs to accept the options
|
|
543
600
|
export async function checkExistingCertificates(options?: ProxyOptions): Promise<SSLConfig | null> {
|
|
544
601
|
if (!options)
|
|
@@ -569,10 +626,15 @@ export async function checkExistingCertificates(options?: ProxyOptions): Promise
|
|
|
569
626
|
const flagValue = options.regenerateUntrustedCerts
|
|
570
627
|
const shouldCheckTrust = hasFlag ? flagValue !== false : true
|
|
571
628
|
debugLog('ssl', `Trust check: hasFlag=${hasFlag}, flagValue=${flagValue}, shouldCheckTrust=${shouldCheckTrust}`, options.verbose)
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
629
|
+
// Trust anchor is the shared Root CA — host certs are not installed individually.
|
|
630
|
+
const sslDir = sslConfig.basePath || join(homedir(), '.stacks', 'ssl')
|
|
631
|
+
const rootCAPaths = getRootCAPaths(sslDir)
|
|
632
|
+
const caIsTrusted = shouldCheckTrust
|
|
633
|
+
? await isCertTrusted(rootCAPaths.caCertPath, options)
|
|
634
|
+
: true
|
|
635
|
+
|
|
636
|
+
if (!caIsTrusted) {
|
|
637
|
+
debugLog('ssl', 'Root CA exists but is not trusted, will regenerate', options.verbose)
|
|
576
638
|
// Don't attempt to trust here - let generateCertificate handle it in one place
|
|
577
639
|
// This avoids multiple sudo prompts
|
|
578
640
|
return null
|
|
@@ -711,39 +773,18 @@ export async function cleanupCertificates(domain: string, verbose?: boolean): Pr
|
|
|
711
773
|
* Checks if a certificate is trusted by the system (macOS only for now)
|
|
712
774
|
* If options.regenerateUntrustedCerts is false, always returns true (skips trust check)
|
|
713
775
|
*/
|
|
714
|
-
export async function isCertTrusted(
|
|
776
|
+
export async function isCertTrusted(
|
|
777
|
+
certPath: string,
|
|
778
|
+
options?: { verbose?: boolean, regenerateUntrustedCerts?: boolean, serverName?: string },
|
|
779
|
+
): Promise<boolean> {
|
|
715
780
|
try {
|
|
716
781
|
debugLog('ssl', `Checking if certificate is trusted: ${certPath}`, options?.verbose)
|
|
717
782
|
|
|
718
783
|
// Different check methods per platform
|
|
719
784
|
if (process.platform === 'darwin') {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const certFingerprint = execSync(`openssl x509 -noout -fingerprint -sha256 -in "${certPath}"`).toString().trim()
|
|
724
|
-
const fingerprintValue = certFingerprint.split('=')[1]?.trim() || ''
|
|
725
|
-
|
|
726
|
-
if (!fingerprintValue) {
|
|
727
|
-
debugLog('ssl', 'Could not extract certificate fingerprint', options?.verbose)
|
|
728
|
-
return false
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// Check if the fingerprint exists in the system keychain and is trusted
|
|
732
|
-
const keychainOutput = execSync(`security find-certificate -a -Z -p | openssl x509 -noout -fingerprint -sha256`).toString()
|
|
733
|
-
|
|
734
|
-
// If the fingerprint is found in the trusted certs, consider it trusted
|
|
735
|
-
if (keychainOutput.includes(fingerprintValue)) {
|
|
736
|
-
debugLog('ssl', 'Certificate fingerprint found in system keychain', options?.verbose)
|
|
737
|
-
return true
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
debugLog('ssl', 'Certificate fingerprint not found in system keychain', options?.verbose)
|
|
741
|
-
return false
|
|
742
|
-
}
|
|
743
|
-
catch (error) {
|
|
744
|
-
debugLog('ssl', `Error checking certificate trust: ${error}`, options?.verbose)
|
|
745
|
-
return false
|
|
746
|
-
}
|
|
785
|
+
if (options?.serverName)
|
|
786
|
+
return isRootCaTrustedForSsl(certPath, options.serverName, { verbose: options.verbose })
|
|
787
|
+
return isRootCaFingerprintInKeychains(certPath, { verbose: options.verbose })
|
|
747
788
|
}
|
|
748
789
|
else if (process.platform === 'win32') {
|
|
749
790
|
// On Windows, use PowerShell to check the certificate store
|
package/src/index.ts
CHANGED
|
@@ -13,13 +13,38 @@ export {
|
|
|
13
13
|
export {
|
|
14
14
|
checkExistingCertificates,
|
|
15
15
|
cleanupCertificates,
|
|
16
|
+
clearSslConfigCache,
|
|
16
17
|
forceTrustCertificate,
|
|
17
18
|
generateCertificate,
|
|
19
|
+
getRootCAPaths,
|
|
20
|
+
getSharedDaemonCertPaths,
|
|
18
21
|
httpsConfig,
|
|
19
22
|
isCertTrusted,
|
|
20
23
|
loadSSLConfig,
|
|
21
24
|
} from './https'
|
|
22
25
|
|
|
26
|
+
export {
|
|
27
|
+
MACOS_CA_TRUST_FLAGS,
|
|
28
|
+
MACOS_SYSTEM_KEYCHAIN,
|
|
29
|
+
RPX_ROOT_CA_COMMON_NAME,
|
|
30
|
+
getMacosLoginKeychainPath,
|
|
31
|
+
getMacosTrustKeychains,
|
|
32
|
+
isRootCaFingerprintInKeychains,
|
|
33
|
+
isRootCaTrustedForSsl,
|
|
34
|
+
listCertSha256HashesByCommonName,
|
|
35
|
+
pruneStaleRootCas,
|
|
36
|
+
trustRootCaForBrowsers,
|
|
37
|
+
} from './macos-trust'
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
certIncludesSanHostnames,
|
|
41
|
+
normalizeSha256Fingerprint,
|
|
42
|
+
parseSha256HashesFromSecurityListing,
|
|
43
|
+
readCertCommonName,
|
|
44
|
+
readCertSha256Fingerprint,
|
|
45
|
+
verifyHttpsChain,
|
|
46
|
+
} from './cert-inspect'
|
|
47
|
+
|
|
23
48
|
export { DefaultPortManager, findAvailablePort, isPortInUse, portManager } from './port-manager'
|
|
24
49
|
|
|
25
50
|
export {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { readCertSha256Fingerprint } from './cert-inspect'
|
|
5
|
+
import { debugLog, execSudoSync } from './utils'
|
|
6
|
+
|
|
7
|
+
/** Chrome/Edge need SSL + basic trust policies — plain trustRoot often leaves "trust settings: 0". */
|
|
8
|
+
export const MACOS_CA_TRUST_FLAGS = '-d -r trustRoot -p ssl -p basic'
|
|
9
|
+
|
|
10
|
+
export const MACOS_SYSTEM_KEYCHAIN = '/Library/Keychains/System.keychain'
|
|
11
|
+
|
|
12
|
+
/** Default CN label for the shared rpx Root CA in macOS keychain listings. */
|
|
13
|
+
export const RPX_ROOT_CA_COMMON_NAME = 'rpx.localhost'
|
|
14
|
+
|
|
15
|
+
export function getMacosLoginKeychainPath(): string {
|
|
16
|
+
return join(homedir(), 'Library/Keychains/login.keychain-db')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getMacosTrustKeychains(): string[] {
|
|
20
|
+
return [MACOS_SYSTEM_KEYCHAIN, getMacosLoginKeychainPath()]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ListCertsByCommonNameOptions {
|
|
24
|
+
keychain: string
|
|
25
|
+
commonName?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function listCertSha256HashesByCommonName(
|
|
29
|
+
keychain: string,
|
|
30
|
+
commonName: string = RPX_ROOT_CA_COMMON_NAME,
|
|
31
|
+
): string[] {
|
|
32
|
+
const listing = execSync(
|
|
33
|
+
`security find-certificate -a -c "${commonName}" -Z "${keychain}" 2>/dev/null || true`,
|
|
34
|
+
{ encoding: 'utf8' },
|
|
35
|
+
)
|
|
36
|
+
const hashes: string[] = []
|
|
37
|
+
for (const line of listing.split('\n')) {
|
|
38
|
+
const match = line.match(/SHA-256 hash:\s*([A-F0-9]+)/i)
|
|
39
|
+
if (match)
|
|
40
|
+
hashes.push(match[1]!.toUpperCase())
|
|
41
|
+
}
|
|
42
|
+
return hashes
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PruneStaleRootCasOptions {
|
|
46
|
+
caPath: string
|
|
47
|
+
commonName?: string
|
|
48
|
+
keychains?: string[]
|
|
49
|
+
verbose?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove older rpx Root CA copies from keychains, keeping only the fingerprint
|
|
54
|
+
* that matches the on-disk `caPath` file.
|
|
55
|
+
*/
|
|
56
|
+
export function pruneStaleRootCas(options: PruneStaleRootCasOptions): void {
|
|
57
|
+
if (process.platform !== 'darwin')
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
const keep = readCertSha256Fingerprint(options.caPath)
|
|
61
|
+
if (!keep)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
const commonName = options.commonName ?? RPX_ROOT_CA_COMMON_NAME
|
|
65
|
+
const keychains = options.keychains ?? getMacosTrustKeychains()
|
|
66
|
+
|
|
67
|
+
for (const keychain of keychains) {
|
|
68
|
+
for (const hash of listCertSha256HashesByCommonName(keychain, commonName)) {
|
|
69
|
+
if (hash === keep)
|
|
70
|
+
continue
|
|
71
|
+
try {
|
|
72
|
+
if (keychain.startsWith('/Library'))
|
|
73
|
+
execSudoSync(`security delete-certificate -Z ${hash} "${keychain}"`)
|
|
74
|
+
else
|
|
75
|
+
execSync(`security delete-certificate -Z ${hash} "${keychain}"`, { stdio: 'ignore' })
|
|
76
|
+
debugLog('ssl', `Removed stale Root CA ${hash} from ${keychain}`, options.verbose)
|
|
77
|
+
}
|
|
78
|
+
catch { /* already removed */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True when the Root CA is trusted for SSL to `serverName` (macOS verify-cert).
|
|
85
|
+
* On other platforms, falls back to fingerprint presence in trust stores.
|
|
86
|
+
*/
|
|
87
|
+
export function isRootCaTrustedForSsl(
|
|
88
|
+
caPath: string,
|
|
89
|
+
serverName: string,
|
|
90
|
+
options?: { verbose?: boolean },
|
|
91
|
+
): boolean {
|
|
92
|
+
if (process.platform !== 'darwin')
|
|
93
|
+
return isRootCaFingerprintInKeychains(caPath, options)
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const out = execSync(
|
|
97
|
+
`security verify-cert -c "${caPath}" -s "${serverName}" -l -L -R ssl 2>&1`,
|
|
98
|
+
{ encoding: 'utf8' },
|
|
99
|
+
)
|
|
100
|
+
const ok = out.includes('successful')
|
|
101
|
+
debugLog('ssl', `verify-cert ${serverName}: ${ok ? 'trusted' : 'not trusted'}`, options?.verbose)
|
|
102
|
+
return ok
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isRootCaFingerprintInKeychains(
|
|
110
|
+
caPath: string,
|
|
111
|
+
options?: { verbose?: boolean },
|
|
112
|
+
): boolean {
|
|
113
|
+
const fp = readCertSha256Fingerprint(caPath)
|
|
114
|
+
if (!fp)
|
|
115
|
+
return false
|
|
116
|
+
|
|
117
|
+
for (const keychain of getMacosTrustKeychains()) {
|
|
118
|
+
try {
|
|
119
|
+
const listing = execSync(`security find-certificate -a -Z "${keychain}" 2>/dev/null || true`, { encoding: 'utf8' })
|
|
120
|
+
for (const line of listing.split('\n')) {
|
|
121
|
+
if (line.toUpperCase().includes('SHA-256')) {
|
|
122
|
+
const lineFp = line.split('=').pop()!.replace(/SHA-256\s+hash:\s*/gi, '').replace(/:/g, '').trim().toUpperCase()
|
|
123
|
+
if (lineFp === fp) {
|
|
124
|
+
debugLog('ssl', `Root CA fingerprint found in ${keychain}`, options?.verbose)
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch { /* try next keychain */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface TrustRootCaForBrowsersOptions {
|
|
137
|
+
serverName: string
|
|
138
|
+
commonName?: string
|
|
139
|
+
verbose?: boolean
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Install the Root CA into login + system keychains with SSL/basic policies,
|
|
144
|
+
* pruning stale copies first. Returns true when SSL verification succeeds or
|
|
145
|
+
* the cert fingerprint is present in a keychain.
|
|
146
|
+
*/
|
|
147
|
+
export function trustRootCaForBrowsers(
|
|
148
|
+
caPath: string,
|
|
149
|
+
options: TrustRootCaForBrowsersOptions,
|
|
150
|
+
): boolean {
|
|
151
|
+
if (process.platform !== 'darwin')
|
|
152
|
+
return false
|
|
153
|
+
|
|
154
|
+
const serverName = options.serverName
|
|
155
|
+
pruneStaleRootCas({ caPath, commonName: options.commonName, verbose: options.verbose })
|
|
156
|
+
|
|
157
|
+
const loginKeychain = getMacosLoginKeychainPath()
|
|
158
|
+
try {
|
|
159
|
+
execSync(
|
|
160
|
+
`security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k "${loginKeychain}" "${caPath}"`,
|
|
161
|
+
{ stdio: 'ignore' },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
catch { /* may already exist — re-apply trust below */ }
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
execSudoSync(`security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k ${MACOS_SYSTEM_KEYCHAIN} "${caPath}"`)
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return isRootCaTrustedForSsl(caPath, serverName, { verbose: options.verbose })
|
|
174
|
+
|| isRootCaFingerprintInKeychains(caPath, { verbose: options.verbose })
|
|
175
|
+
}
|