@stacksjs/rpx 0.11.8 → 0.11.10

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.
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/rpx",
3
3
  "type": "module",
4
- "version": "0.11.8",
4
+ "version": "0.11.10",
5
5
  "description": "A modern and smart reverse proxy.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -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.ts CHANGED
@@ -28,6 +28,11 @@ import { checkExistingCertificates, generateCertificate } from './https'
28
28
  import { createProxyFetchHandler } from './proxy-handler'
29
29
  import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
30
30
  import type { RegistryEntry } from './registry'
31
+ import {
32
+ reconcileStaleDevelopmentDns,
33
+ syncDevelopmentDnsFromRegistry,
34
+ tearDownDevelopmentDns,
35
+ } from './dns'
31
36
  import { debugLog } from './utils'
32
37
 
33
38
  export interface DaemonOptions {
@@ -236,7 +241,15 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
236
241
  await gcStaleEntries(registryDir, verbose).catch((err) => {
237
242
  debugLog('daemon', `initial gc failed: ${err}`, verbose)
238
243
  })
239
- rebuild(await readAll(registryDir, verbose))
244
+ const initialEntries = await readAll(registryDir, verbose)
245
+ rebuild(initialEntries)
246
+
247
+ await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
248
+ debugLog('daemon', `DNS reconcile on start failed: ${err}`, verbose)
249
+ })
250
+ await syncDevelopmentDnsFromRegistry(initialEntries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
251
+ debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
252
+ })
240
253
 
241
254
  const sslConfig = await bootstrapTls(opts, registryDir)
242
255
 
@@ -280,7 +293,12 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
280
293
  }
281
294
 
282
295
  const watcher = watchRegistry(
283
- (entries) => { rebuild(entries) },
296
+ (entries) => {
297
+ rebuild(entries)
298
+ syncDevelopmentDnsFromRegistry(entries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
299
+ debugLog('daemon', `DNS sync on registry change failed: ${err}`, verbose)
300
+ })
301
+ },
284
302
  { dir: registryDir, verbose },
285
303
  )
286
304
 
@@ -311,6 +329,9 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
311
329
  // `stop(false)` lets in-flight requests drain before closing the listener.
312
330
  httpsServer.stop(false)
313
331
  httpServer?.stop(false)
332
+ await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
333
+ debugLog('daemon', `DNS teardown failed: ${err}`, verbose)
334
+ })
314
335
  await releaseDaemonLock(rpxDir)
315
336
  if (verbose)
316
337
  log.info('rpx daemon stopped')
@@ -391,6 +412,10 @@ export async function ensureDaemonRunning(opts: EnsureDaemonOptions = {}): Promi
391
412
  const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
392
413
  const verbose = opts.verbose ?? false
393
414
 
415
+ await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
416
+ debugLog('daemon', `DNS reconcile before ensureDaemonRunning: ${err}`, verbose)
417
+ })
418
+
394
419
  const existingPid = await readDaemonPid(rpxDir)
395
420
  if (existingPid !== null && isPidAlive(existingPid)) {
396
421
  debugLog('daemon', `ensureDaemonRunning: already running pid=${existingPid}`, verbose)
@@ -477,6 +502,7 @@ export async function stopDaemon(opts: StopDaemonOptions = {}): Promise<StopDaem
477
502
  if (pid === null || !isPidAlive(pid)) {
478
503
  if (pid !== null)
479
504
  await releaseDaemonLock(rpxDir)
505
+ await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch(() => {})
480
506
  return { stopped: false, pid, forced: false }
481
507
  }
482
508
 
@@ -514,5 +540,18 @@ export async function stopDaemon(opts: StopDaemonOptions = {}): Promise<StopDaem
514
540
  }
515
541
  // SIGKILL bypasses the cleanup handler, so remove the pid file ourselves.
516
542
  await releaseDaemonLock(rpxDir)
543
+ await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
544
+ debugLog('daemon', `DNS teardown after SIGKILL: ${err}`, verbose)
545
+ })
517
546
  return { stopped: true, pid, forced: true }
518
547
  }
548
+
549
+ /**
550
+ * When the daemon is not running, ensure no stale macOS resolver overrides remain.
551
+ */
552
+ export async function reconcileDevelopmentDnsOnIdle(opts: { rpxDir?: string, verbose?: boolean } = {}): Promise<void> {
553
+ const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
554
+ if (await isDaemonRunning(rpxDir))
555
+ return
556
+ await reconcileStaleDevelopmentDns({ rpxDir, verbose: opts.verbose })
557
+ }
@@ -0,0 +1,116 @@
1
+ import * as fsp from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import * as path from 'node:path'
4
+
5
+ export const DNS_STATE_VERSION = 1 as const
6
+ export const RPX_DNS_STATE_FILE = 'dns-state.json'
7
+
8
+ /** Single-label /etc/resolver files created by older rpx versions (whole-TLD hijack). */
9
+ export const LEGACY_TLD_RESOLVER_LABELS = [
10
+ 'com',
11
+ 'test',
12
+ 'dev',
13
+ 'app',
14
+ 'page',
15
+ 'local',
16
+ 'localhost',
17
+ 'example',
18
+ 'invalid',
19
+ ] as const
20
+
21
+ export interface DnsState {
22
+ version: typeof DNS_STATE_VERSION
23
+ /** Basenames under /etc/resolver/ (e.g. `postline.test`, not `test`). */
24
+ resolvers: string[]
25
+ domains: string[]
26
+ ownerPid: number | null
27
+ updatedAt: string
28
+ }
29
+
30
+ export function defaultRpxDir(): string {
31
+ return path.join(homedir(), '.stacks', 'rpx')
32
+ }
33
+
34
+ export function getDnsStatePath(rpxDir: string = defaultRpxDir()): string {
35
+ return path.join(rpxDir, RPX_DNS_STATE_FILE)
36
+ }
37
+
38
+ export async function loadDnsState(rpxDir: string = defaultRpxDir()): Promise<DnsState | null> {
39
+ try {
40
+ const raw = await fsp.readFile(getDnsStatePath(rpxDir), 'utf8')
41
+ const parsed = JSON.parse(raw) as Partial<DnsState>
42
+ if (parsed.version !== DNS_STATE_VERSION || !Array.isArray(parsed.resolvers))
43
+ return null
44
+ return {
45
+ version: DNS_STATE_VERSION,
46
+ resolvers: parsed.resolvers.filter((r): r is string => typeof r === 'string'),
47
+ domains: Array.isArray(parsed.domains)
48
+ ? parsed.domains.filter((d): d is string => typeof d === 'string')
49
+ : [],
50
+ ownerPid: typeof parsed.ownerPid === 'number' ? parsed.ownerPid : null,
51
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
52
+ }
53
+ }
54
+ catch (err) {
55
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT')
56
+ return null
57
+ throw err
58
+ }
59
+ }
60
+
61
+ export async function saveDnsState(rpxDir: string, state: DnsState): Promise<void> {
62
+ await fsp.mkdir(rpxDir, { recursive: true })
63
+ await fsp.writeFile(getDnsStatePath(rpxDir), `${JSON.stringify(state, null, 2)}\n`, 'utf8')
64
+ }
65
+
66
+ export async function clearDnsState(rpxDir: string): Promise<void> {
67
+ await fsp.rm(getDnsStatePath(rpxDir), { force: true })
68
+ }
69
+
70
+ /**
71
+ * Normalize a dev hostname. Returns null for localhost / IPs — those use /etc/hosts only.
72
+ */
73
+ export function normalizeDevDomain(raw: string): string | null {
74
+ const domain = raw.trim().toLowerCase().replace(/\.$/, '')
75
+ if (!domain || domain.includes('127.0.0.1'))
76
+ return null
77
+ if (domain === 'localhost' || domain.endsWith('.localhost'))
78
+ return null
79
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(domain))
80
+ return null
81
+ return domain
82
+ }
83
+
84
+ /**
85
+ * macOS resolver basename for a dev domain. Uses the registrable base (last two labels)
86
+ * so `api.postline.test` and `postline.test` share one `/etc/resolver/postline.test` file.
87
+ */
88
+ export function resolverBasenameForDomain(raw: string): string | null {
89
+ const domain = normalizeDevDomain(raw)
90
+ if (!domain)
91
+ return null
92
+ const parts = domain.split('.')
93
+ if (parts.length < 2)
94
+ return null
95
+ return parts.slice(-2).join('.')
96
+ }
97
+
98
+ export function resolverBasenamesForDomains(domains: string[]): string[] {
99
+ const basenames = new Set<string>()
100
+ for (const raw of domains) {
101
+ const basename = resolverBasenameForDomain(raw)
102
+ if (basename)
103
+ basenames.add(basename)
104
+ }
105
+ return Array.from(basenames).sort()
106
+ }
107
+
108
+ export function devDomainsFromHosts(hosts: string[]): string[] {
109
+ const out = new Set<string>()
110
+ for (const raw of hosts) {
111
+ const domain = normalizeDevDomain(raw)
112
+ if (domain)
113
+ out.add(domain)
114
+ }
115
+ return Array.from(out).sort()
116
+ }