@stacksjs/rpx 0.11.17 → 0.11.19
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 +162 -148
- package/dist/chunk-0f32jmrb.js +1 -0
- package/dist/chunk-83dqq28c.js +1 -0
- package/dist/chunk-rs8gqpax.js +174 -0
- package/dist/{chunk-rbgb5fyg.js → chunk-w888yhnp.js} +12 -12
- package/dist/daemon.d.ts +17 -8
- package/dist/dns.d.ts +2 -0
- package/dist/https.d.ts +9 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -7
- package/dist/logger.d.ts +8 -8
- package/dist/proxy-handler.d.ts +1 -1
- package/dist/proxy-pool.d.ts +77 -0
- package/dist/types.d.ts +12 -0
- package/dist/utils.d.ts +15 -0
- package/package.json +6 -1
- package/src/daemon.ts +471 -48
- package/src/dns.ts +66 -7
- package/src/hosts.ts +6 -1
- package/src/https.ts +46 -3
- package/src/index.ts +3 -0
- package/src/logger.ts +16 -16
- package/src/origin-guard.ts +27 -5
- package/src/proxy-handler.ts +122 -50
- package/src/proxy-pool.ts +1003 -0
- package/src/start.ts +40 -33
- package/src/types.ts +19 -0
- package/src/utils.ts +33 -0
- package/dist/chunk-a0b9f9fs.js +0 -1
- package/dist/chunk-fndafyac.js +0 -1
- package/dist/chunk-zx2ghrc1.js +0 -161
package/src/daemon.ts
CHANGED
|
@@ -24,7 +24,15 @@ import { homedir } from 'node:os'
|
|
|
24
24
|
import * as path from 'node:path'
|
|
25
25
|
import * as process from 'node:process'
|
|
26
26
|
import { log } from './logger'
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
buildRegistryTlsProxyOptions,
|
|
29
|
+
certIncludesSanHostnames,
|
|
30
|
+
checkExistingCertificates,
|
|
31
|
+
clearSslConfigCache,
|
|
32
|
+
devSslToSniEntries,
|
|
33
|
+
generateCertificate,
|
|
34
|
+
SHARED_DEV_HOST_CERT_PATH,
|
|
35
|
+
} from './https'
|
|
28
36
|
import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
|
|
29
37
|
import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
|
|
30
38
|
import type { HostRoutes } from './host-routes'
|
|
@@ -38,7 +46,7 @@ import {
|
|
|
38
46
|
syncDevelopmentDnsFromRegistry,
|
|
39
47
|
tearDownDevelopmentDns,
|
|
40
48
|
} from './dns'
|
|
41
|
-
import { debugLog } from './utils'
|
|
49
|
+
import { debugLog, shouldReusePort } from './utils'
|
|
42
50
|
|
|
43
51
|
export interface DaemonOptions {
|
|
44
52
|
verbose?: boolean
|
|
@@ -68,6 +76,15 @@ export interface DaemonOptions {
|
|
|
68
76
|
onDemandTls?: OnDemandTlsConfig
|
|
69
77
|
/** PID-GC interval in ms. Defaults to 5000. */
|
|
70
78
|
gcIntervalMs?: number
|
|
79
|
+
/**
|
|
80
|
+
* Run as a multi-core cluster: a coordinator owns the singletons (lock, certs,
|
|
81
|
+
* DNS, hosts, :80 ACME/redirect) and spawns this many worker processes that
|
|
82
|
+
* bind :443 with `reusePort` and serve traffic. Defaults to 1 (single process).
|
|
83
|
+
* Also settable via `RPX_WORKERS`. On Linux the kernel load-balances accepted
|
|
84
|
+
* connections across workers; on macOS `SO_REUSEPORT` doesn't, so it falls back
|
|
85
|
+
* to effectively one active worker (still correct, just not parallel).
|
|
86
|
+
*/
|
|
87
|
+
workers?: number
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
export interface DaemonHandle {
|
|
@@ -207,33 +224,24 @@ function pickPrimaryRegistryHost(hosts: string[]): string {
|
|
|
207
224
|
|
|
208
225
|
async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<SSLConfig> {
|
|
209
226
|
const entries = await readAll(registryDir, opts.verbose)
|
|
210
|
-
const registryHosts = [...new Set(entries.map(e => e.to))]
|
|
227
|
+
const registryHosts = [...new Set(entries.map(e => e.to).filter(Boolean))]
|
|
211
228
|
const primary = pickPrimaryRegistryHost(registryHosts)
|
|
212
229
|
const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
|
|
213
230
|
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const proxyOpts: ProxyOptions = {
|
|
218
|
-
https: typeof opts.https === 'object'
|
|
219
|
-
? { ...opts.https, certPath: sharedCert, keyPath: path.join(sslDir, 'rpx.localhost.key'), commonName: primary }
|
|
220
|
-
: {
|
|
221
|
-
certPath: sharedCert,
|
|
222
|
-
keyPath: path.join(sslDir, 'rpx.localhost.key'),
|
|
223
|
-
caCertPath: path.join(sslDir, 'rpx.localhost.ca.crt'),
|
|
224
|
-
commonName: primary,
|
|
225
|
-
},
|
|
226
|
-
verbose: opts.verbose,
|
|
227
|
-
regenerateUntrustedCerts: true,
|
|
228
|
-
...(hostnames.length > 1
|
|
229
|
-
? { proxies: hostnames.map(to => ({ from: 'localhost:1', to })) }
|
|
230
|
-
: { to: primary, from: 'localhost:1' }),
|
|
231
|
-
}
|
|
231
|
+
const proxyOpts = buildRegistryTlsProxyOptions(registryHosts, primary, opts.verbose)
|
|
232
|
+
if (typeof opts.https === 'object' && typeof proxyOpts.https === 'object')
|
|
233
|
+
proxyOpts.https = { ...proxyOpts.https, ...opts.https }
|
|
232
234
|
|
|
233
235
|
let sslConfig = await checkExistingCertificates(proxyOpts)
|
|
236
|
+
if (sslConfig && !certIncludesSanHostnames(SHARED_DEV_HOST_CERT_PATH, hostnames)) {
|
|
237
|
+
debugLog('daemon', `shared cert missing SANs for registry host(s), regenerating (${hostnames.join(', ')})`, opts.verbose)
|
|
238
|
+
clearSslConfigCache()
|
|
239
|
+
sslConfig = null
|
|
240
|
+
}
|
|
241
|
+
|
|
234
242
|
if (!sslConfig) {
|
|
235
|
-
debugLog('daemon', 'no usable cert on disk, generating one', opts.verbose)
|
|
236
|
-
await generateCertificate(proxyOpts)
|
|
243
|
+
debugLog('daemon', 'no usable cert on disk, generating one via tlsx', opts.verbose)
|
|
244
|
+
await generateCertificate({ ...proxyOpts, forceRegenerate: true } as ProxyOptions & { forceRegenerate?: boolean })
|
|
237
245
|
sslConfig = await checkExistingCertificates(proxyOpts)
|
|
238
246
|
}
|
|
239
247
|
if (!sslConfig)
|
|
@@ -333,10 +341,69 @@ async function elevateDaemonToRoot(
|
|
|
333
341
|
* listeners are bound and the initial routing table is populated. Use
|
|
334
342
|
* `handle.done` for the lifetime promise.
|
|
335
343
|
*/
|
|
344
|
+
let crashGuardsInstalled = false
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* A reverse proxy must outlive a single bad request. A stray uncaught exception
|
|
348
|
+
* or unhandled rejection — a malformed upstream response, a registry-watcher
|
|
349
|
+
* callback bug, a `void`-ed promise — would otherwise crash the whole daemon and
|
|
350
|
+
* drop :443 for *every* host behind it. Log and keep serving; systemd still
|
|
351
|
+
* restarts a genuinely fatal exit, but one bad request no longer takes the
|
|
352
|
+
* gateway down. Idempotent so the worker/coordinator entry points can each call
|
|
353
|
+
* it without stacking duplicate handlers.
|
|
354
|
+
*/
|
|
355
|
+
function installDaemonCrashGuards(): void {
|
|
356
|
+
if (crashGuardsInstalled)
|
|
357
|
+
return
|
|
358
|
+
crashGuardsInstalled = true
|
|
359
|
+
process.on('uncaughtException', (err) => {
|
|
360
|
+
log.error(`rpx daemon: uncaught exception (continuing): ${(err as Error)?.stack ?? err}`)
|
|
361
|
+
})
|
|
362
|
+
process.on('unhandledRejection', (reason) => {
|
|
363
|
+
log.error(`rpx daemon: unhandled rejection (continuing): ${reason}`)
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The shared `:80` handler: serve ACME http-01 challenges, kick off on-demand
|
|
369
|
+
* issuance for an approved-but-uncovered host, then 301 to HTTPS. The request
|
|
370
|
+
* target is parsed defensively — scanners constantly send malformed/relative
|
|
371
|
+
* targets, and a thrown `new URL` would reject the fetch handler and make Bun
|
|
372
|
+
* drop the connection with no response. A bad target becomes a 400 instead.
|
|
373
|
+
*/
|
|
374
|
+
function handleHttpRedirect(req: Request, onDemand: OnDemandCertManager | null): Response {
|
|
375
|
+
let u: URL
|
|
376
|
+
try {
|
|
377
|
+
u = new URL(req.url)
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return new Response('Bad Request', { status: 400 })
|
|
381
|
+
}
|
|
382
|
+
const host = (req.headers.get('host') ?? u.hostname).split(':')[0]
|
|
383
|
+
|
|
384
|
+
if (onDemand && u.pathname.startsWith('/.well-known/acme-challenge/')) {
|
|
385
|
+
const keyAuth = onDemand.challengeStore.handlePath(u.pathname)
|
|
386
|
+
if (keyAuth !== undefined)
|
|
387
|
+
return new Response(keyAuth, { status: 200, headers: { 'content-type': 'text/plain' } })
|
|
388
|
+
return new Response('challenge not found', { status: 404 })
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// First plaintext hit for an approved-but-uncovered host: kick off issuance so
|
|
392
|
+
// the cert exists for the subsequent HTTPS request (don't block the redirect).
|
|
393
|
+
if (onDemand && !onDemand.hasCert(host))
|
|
394
|
+
onDemand.ensureCert(host).catch(() => {})
|
|
395
|
+
|
|
396
|
+
return new Response(null, {
|
|
397
|
+
status: 301,
|
|
398
|
+
headers: { Location: `https://${host}${u.pathname}${u.search}` },
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
336
402
|
// `opts` IS used throughout; pickier's no-unused-vars mis-fires on this fn after
|
|
337
403
|
// the on-demand serve refactor (its --fix would wrongly rename to `_opts`).
|
|
338
404
|
// eslint-disable-next-line pickier/no-unused-vars
|
|
339
405
|
export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {
|
|
406
|
+
installDaemonCrashGuards()
|
|
340
407
|
const verbose = opts.verbose ?? false
|
|
341
408
|
const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
|
|
342
409
|
const registryDir = opts.registryDir ?? path.join(rpxDir, 'registry.d')
|
|
@@ -345,6 +412,20 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
345
412
|
const hostname = opts.hostname ?? '0.0.0.0'
|
|
346
413
|
const gcIntervalMs = opts.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
|
|
347
414
|
|
|
415
|
+
// A spawned cluster worker (RPX_DAEMON_WORKER=1) serves :443 only and is
|
|
416
|
+
// configured entirely via RPX_WORKER_* env (set by the coordinator). Handled
|
|
417
|
+
// before the privileged-port check because a worker never self-elevates — it
|
|
418
|
+
// inherits the coordinator's privileges and gets its port from env.
|
|
419
|
+
if (process.env.RPX_DAEMON_WORKER === '1') {
|
|
420
|
+
return runDaemonWorker({
|
|
421
|
+
rpxDir: process.env.RPX_WORKER_RPXDIR ?? rpxDir,
|
|
422
|
+
registryDir: process.env.RPX_WORKER_REGISTRYDIR ?? registryDir,
|
|
423
|
+
httpsPort: Number.parseInt(process.env.RPX_WORKER_HTTPSPORT ?? '', 10) || httpsPort,
|
|
424
|
+
hostname: process.env.RPX_WORKER_HOSTNAME ?? hostname,
|
|
425
|
+
verbose: process.env.RPX_WORKER_VERBOSE === '1' || verbose,
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
348
429
|
// Privileged ports need root. If we were launched unprivileged (the usual
|
|
349
430
|
// `./buddy dev` case), re-exec through sudo and hand off to the elevated
|
|
350
431
|
// copy — it becomes the real daemon. Tests inject high ports and so skip this.
|
|
@@ -353,6 +434,11 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
353
434
|
if (process.platform !== 'win32' && needsPrivilegedPort && !alreadyRoot)
|
|
354
435
|
return elevateDaemonToRoot(rpxDir, httpsPort, httpPort, verbose)
|
|
355
436
|
|
|
437
|
+
// Cluster coordinator: owns the singletons and spawns N workers that bind :443.
|
|
438
|
+
const workers = Math.max(1, opts.workers ?? (Number.parseInt(process.env.RPX_WORKERS ?? '', 10) || 1))
|
|
439
|
+
if (workers > 1)
|
|
440
|
+
return runDaemonCoordinator(opts, { rpxDir, registryDir, httpsPort, httpPort, hostname, verbose, gcIntervalMs, workers })
|
|
441
|
+
|
|
356
442
|
const pidPath = await acquireDaemonLock(rpxDir)
|
|
357
443
|
|
|
358
444
|
// Module-scoped state so the watcher and fetch handler share one routing view.
|
|
@@ -421,7 +507,7 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
421
507
|
: null
|
|
422
508
|
|
|
423
509
|
/** Build the TLS option for Bun.serve from the current SNI set (or dev cert). */
|
|
424
|
-
function tlsFor(entries: Array<{ serverName: string, cert: string, key: string }>):
|
|
510
|
+
function tlsFor(entries: Array<{ serverName: string, cert: string, key: string }>): Bun.TLSOptions | Bun.TLSOptions[] {
|
|
425
511
|
if (entries.length > 0)
|
|
426
512
|
return entries.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
|
|
427
513
|
// No real certs: fall back to the dev self-signed shared cert.
|
|
@@ -439,7 +525,10 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
439
525
|
return Bun.serve({
|
|
440
526
|
port: httpsPort,
|
|
441
527
|
hostname,
|
|
442
|
-
|
|
528
|
+
// Opt-in (RPX_REUSE_PORT): multi-instance :443 sharing on Linux. Off by
|
|
529
|
+
// default — see shouldReusePort(). rpx never spawns its own cluster.
|
|
530
|
+
reusePort: shouldReusePort(),
|
|
531
|
+
tls: tlsFor(entries),
|
|
443
532
|
fetch(req: Request, server: unknown) {
|
|
444
533
|
return fetchHandler(req, server as ProxyServerLike)
|
|
445
534
|
},
|
|
@@ -451,7 +540,20 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
451
540
|
})
|
|
452
541
|
}
|
|
453
542
|
|
|
454
|
-
|
|
543
|
+
const registryHostsForTls = (entries: RegistryEntry[]) =>
|
|
544
|
+
[...new Set(entries.map(e => e.to).filter(Boolean))]
|
|
545
|
+
|
|
546
|
+
const devTlsEntries = (entries: RegistryEntry[]) => {
|
|
547
|
+
if (!devSslConfig)
|
|
548
|
+
return sniTls
|
|
549
|
+
return devSslToSniEntries([...registryHostsForTls(entries), 'rpx.localhost'], devSslConfig)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let httpsServer = serveHttps(
|
|
553
|
+
onDemand
|
|
554
|
+
? onDemand.sniEntries()
|
|
555
|
+
: (sniTls.length > 0 ? sniTls : devTlsEntries(initialEntries)),
|
|
556
|
+
)
|
|
455
557
|
|
|
456
558
|
/**
|
|
457
559
|
* Bun has no working SNICallback and `server.reload({ tls })` does not update
|
|
@@ -488,28 +590,10 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
488
590
|
port: httpPort,
|
|
489
591
|
hostname,
|
|
490
592
|
fetch(req: Request) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (onDemand && u.pathname.startsWith('/.well-known/acme-challenge/')) {
|
|
496
|
-
const keyAuth = onDemand.challengeStore.handlePath(u.pathname)
|
|
497
|
-
if (keyAuth !== undefined)
|
|
498
|
-
return new Response(keyAuth, { status: 200, headers: { 'content-type': 'text/plain' } })
|
|
499
|
-
return new Response('challenge not found', { status: 404 })
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// First plaintext hit for an approved-but-uncovered host: kick off
|
|
503
|
-
// issuance so the cert exists for the subsequent HTTPS request. We don't
|
|
504
|
-
// block the redirect on it (the browser retries over HTTPS anyway).
|
|
505
|
-
if (onDemand && !onDemand.hasCert(host)) {
|
|
506
|
-
onDemand.ensureCert(host).catch(() => {})
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return new Response(null, {
|
|
510
|
-
status: 301,
|
|
511
|
-
headers: { Location: `https://${host}${u.pathname}${u.search}` },
|
|
512
|
-
})
|
|
593
|
+
return handleHttpRedirect(req, onDemand)
|
|
594
|
+
},
|
|
595
|
+
error() {
|
|
596
|
+
return new Response('Bad Request', { status: 400 })
|
|
513
597
|
},
|
|
514
598
|
})
|
|
515
599
|
}
|
|
@@ -520,9 +604,23 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
520
604
|
log.info(`registry: ${registryDir}`)
|
|
521
605
|
}
|
|
522
606
|
|
|
607
|
+
async function syncDevTlsWithRegistry(entries: RegistryEntry[]): Promise<void> {
|
|
608
|
+
if (stopped || sniTls.length > 0 || onDemand || !devSslConfig)
|
|
609
|
+
return
|
|
610
|
+
try {
|
|
611
|
+
const refreshed = await bootstrapTls(opts, registryDir)
|
|
612
|
+
devSslConfig = refreshed
|
|
613
|
+
await rebuildTls(devTlsEntries(entries))
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
debugLog('daemon', `TLS sync on registry change failed: ${err}`, verbose)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
523
620
|
const watcher = watchRegistry(
|
|
524
621
|
(entries) => {
|
|
525
622
|
rebuild(entries)
|
|
623
|
+
void syncDevTlsWithRegistry(entries)
|
|
526
624
|
syncDevelopmentDnsFromRegistry(entries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
|
|
527
625
|
debugLog('daemon', `DNS sync on registry change failed: ${err}`, verbose)
|
|
528
626
|
})
|
|
@@ -584,6 +682,331 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
584
682
|
}
|
|
585
683
|
}
|
|
586
684
|
|
|
685
|
+
// ───────────────────────── cluster: coordinator + workers ─────────────────────
|
|
686
|
+
|
|
687
|
+
interface WorkerCtx {
|
|
688
|
+
rpxDir: string
|
|
689
|
+
registryDir: string
|
|
690
|
+
httpsPort: number
|
|
691
|
+
hostname: string
|
|
692
|
+
verbose: boolean
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
interface CoordinatorCtx extends WorkerCtx {
|
|
696
|
+
httpPort: number
|
|
697
|
+
gcIntervalMs: number
|
|
698
|
+
workers: number
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
type SniEntry = { serverName: string, cert: string, key: string }
|
|
702
|
+
interface ClusterSni { sni: SniEntry[], dev: { key: string, cert: string, ca?: string } | null }
|
|
703
|
+
|
|
704
|
+
/** Path of the file the coordinator publishes the live SNI set to for workers. */
|
|
705
|
+
function clusterSniPath(rpxDir: string): string {
|
|
706
|
+
return path.join(rpxDir, 'cluster-sni.json')
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Atomically publish the current cert material so a worker never reads a partial file. */
|
|
710
|
+
async function writeClusterSni(rpxDir: string, sni: SniEntry[], dev: ClusterSni['dev']): Promise<void> {
|
|
711
|
+
const target = clusterSniPath(rpxDir)
|
|
712
|
+
const tmp = `${target}.${process.pid}.tmp`
|
|
713
|
+
await fsp.writeFile(tmp, JSON.stringify({ sni, dev } satisfies ClusterSni), 'utf8')
|
|
714
|
+
await fsp.rename(tmp, target)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function readClusterSni(rpxDir: string): Promise<ClusterSni> {
|
|
718
|
+
try {
|
|
719
|
+
return JSON.parse(await fsp.readFile(clusterSniPath(rpxDir), 'utf8')) as ClusterSni
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
return { sni: [], dev: null }
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Build the Bun.serve `tls` option from a published SNI set (or the dev fallback). */
|
|
727
|
+
function clusterTlsFor(cfg: ClusterSni): Bun.TLSOptions | Bun.TLSOptions[] | undefined {
|
|
728
|
+
if (cfg.sni.length > 0)
|
|
729
|
+
return cfg.sni.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
|
|
730
|
+
if (cfg.dev)
|
|
731
|
+
return { key: cfg.dev.key, cert: cfg.dev.cert, ca: cfg.dev.ca, requestCert: false, rejectUnauthorized: false }
|
|
732
|
+
return undefined // no certs published yet; handshakes fail until the first SIGHUP reload
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Command used to re-exec this process as a cluster worker. `RPX_WORKER_BIN`
|
|
737
|
+
* overrides the script path — needed when `argv[1]` isn't the rpx entrypoint
|
|
738
|
+
* (e.g. under a test runner, or an unusual deployment layout).
|
|
739
|
+
*/
|
|
740
|
+
function workerSpawnCommand(): string[] {
|
|
741
|
+
const exec = process.execPath
|
|
742
|
+
const binOverride = process.env.RPX_WORKER_BIN
|
|
743
|
+
if (binOverride)
|
|
744
|
+
return [exec, binOverride, 'daemon:start']
|
|
745
|
+
const interpName = path.basename(exec).toLowerCase()
|
|
746
|
+
const isInterpreter = interpName === 'bun' || interpName === 'node' || interpName.startsWith('bun-')
|
|
747
|
+
if (isInterpreter && process.argv[1])
|
|
748
|
+
return [exec, process.argv[1], 'daemon:start']
|
|
749
|
+
return [exec, 'daemon:start']
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* A cluster worker: binds :443 with `reusePort`, serves the proxy handler, keeps
|
|
754
|
+
* its routing table in sync with the registry, and reloads its TLS certs from
|
|
755
|
+
* the coordinator-published file on `SIGHUP`. It owns none of the singletons
|
|
756
|
+
* (lock, DNS, hosts, :80, ACME issuance) — the coordinator does.
|
|
757
|
+
*/
|
|
758
|
+
export async function runDaemonWorker(ctx: WorkerCtx): Promise<DaemonHandle> {
|
|
759
|
+
installDaemonCrashGuards()
|
|
760
|
+
const { rpxDir, registryDir, httpsPort, hostname, verbose } = ctx
|
|
761
|
+
|
|
762
|
+
let routingTable: HostRoutes<ProxyRoute> = new Map()
|
|
763
|
+
const getRoute = (host: string, pathname: string): ProxyRoute | undefined =>
|
|
764
|
+
matchHostRoute(routingTable, host, pathname)
|
|
765
|
+
const rebuild = (entries: RegistryEntry[]): void => {
|
|
766
|
+
routingTable = buildHostRoutes(entries.map(e => ({ host: e.to, path: e.path, route: entryToRoute(e) })))
|
|
767
|
+
}
|
|
768
|
+
rebuild(await readAll(registryDir, verbose))
|
|
769
|
+
|
|
770
|
+
const fetchHandler = createProxyFetchHandler(getRoute, verbose)
|
|
771
|
+
const wsHandler = createProxyWebSocketHandler(verbose)
|
|
772
|
+
|
|
773
|
+
let stopped = false
|
|
774
|
+
const serve = (cfg: ClusterSni): ReturnType<typeof Bun.serve> => Bun.serve({
|
|
775
|
+
port: httpsPort,
|
|
776
|
+
hostname,
|
|
777
|
+
reusePort: true, // workers share :443; the kernel load-balances (on Linux)
|
|
778
|
+
tls: clusterTlsFor(cfg),
|
|
779
|
+
fetch(req: Request, server: unknown) {
|
|
780
|
+
return fetchHandler(req, server as ProxyServerLike)
|
|
781
|
+
},
|
|
782
|
+
websocket: wsHandler,
|
|
783
|
+
error(err: Error) {
|
|
784
|
+
debugLog('daemon', `worker https error: ${err}`, verbose)
|
|
785
|
+
return new Response(`Server Error: ${err.message}`, { status: 500 })
|
|
786
|
+
},
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
let httpsServer = serve(await readClusterSni(rpxDir))
|
|
790
|
+
|
|
791
|
+
// Bun can't hot-swap TLS, so reload = tear down + re-bind with the new certs
|
|
792
|
+
// (same approach as the solo daemon's rebuildTls). reusePort keeps the rebind
|
|
793
|
+
// from racing sibling workers off the port.
|
|
794
|
+
async function reloadTls(): Promise<void> {
|
|
795
|
+
if (stopped)
|
|
796
|
+
return
|
|
797
|
+
const cfg = await readClusterSni(rpxDir)
|
|
798
|
+
httpsServer.stop(false)
|
|
799
|
+
for (let attempt = 0; attempt < 20 && !stopped; attempt++) {
|
|
800
|
+
try {
|
|
801
|
+
httpsServer = serve(cfg)
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const watcher = watchRegistry(entries => rebuild(entries), { dir: registryDir, verbose })
|
|
811
|
+
const onHup = (): void => { reloadTls().catch(() => {}) }
|
|
812
|
+
process.on('SIGHUP', onHup)
|
|
813
|
+
|
|
814
|
+
let resolveDone!: () => void
|
|
815
|
+
const done = new Promise<void>((r) => { resolveDone = r })
|
|
816
|
+
async function stop(): Promise<void> {
|
|
817
|
+
if (stopped)
|
|
818
|
+
return done
|
|
819
|
+
stopped = true
|
|
820
|
+
process.off('SIGHUP', onHup)
|
|
821
|
+
watcher.close()
|
|
822
|
+
httpsServer.stop(false)
|
|
823
|
+
resolveDone()
|
|
824
|
+
return done
|
|
825
|
+
}
|
|
826
|
+
const onSignal = (): void => { stop().then(() => process.exit(0)).catch(() => process.exit(0)) }
|
|
827
|
+
process.once('SIGTERM', onSignal)
|
|
828
|
+
process.once('SIGINT', onSignal)
|
|
829
|
+
|
|
830
|
+
if (verbose)
|
|
831
|
+
log.success(`rpx worker (pid ${process.pid}) serving :${httpsPort}`)
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
stop,
|
|
835
|
+
done,
|
|
836
|
+
httpsPort: typeof httpsServer.port === 'number' ? httpsServer.port : httpsPort,
|
|
837
|
+
httpPort: 0,
|
|
838
|
+
pidPath: '',
|
|
839
|
+
ensureCert: () => Promise.resolve(false),
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* The cluster coordinator: owns the lock, certs, DNS, hosts, registry GC, and the
|
|
845
|
+
* :80 listener (ACME http-01 + HTTP→HTTPS redirect). It does NOT bind :443 —
|
|
846
|
+
* instead it spawns {@link CoordinatorCtx.workers} worker processes that do, and
|
|
847
|
+
* republishes the SNI set (+ SIGHUPs the workers) whenever an on-demand cert is
|
|
848
|
+
* issued. Workers that crash are respawned.
|
|
849
|
+
*/
|
|
850
|
+
async function runDaemonCoordinator(opts: DaemonOptions, ctx: CoordinatorCtx): Promise<DaemonHandle> {
|
|
851
|
+
installDaemonCrashGuards()
|
|
852
|
+
const { rpxDir, registryDir, httpsPort, httpPort, hostname, verbose, gcIntervalMs, workers } = ctx
|
|
853
|
+
const pidPath = await acquireDaemonLock(rpxDir)
|
|
854
|
+
|
|
855
|
+
// Bootstrap certs to disk + assemble the initial SNI set.
|
|
856
|
+
let sniTls: SniEntry[] = []
|
|
857
|
+
if (opts.productionCerts)
|
|
858
|
+
sniTls = await buildSniTlsConfig(opts.productionCerts, verbose)
|
|
859
|
+
let devSslConfig: SSLConfig | null = null
|
|
860
|
+
if (sniTls.length === 0)
|
|
861
|
+
devSslConfig = await bootstrapTls(opts, registryDir)
|
|
862
|
+
const dev: ClusterSni['dev'] = devSslConfig
|
|
863
|
+
? { key: devSslConfig.key, cert: devSslConfig.cert, ca: Array.isArray(devSslConfig.ca) ? devSslConfig.ca.join('\n') : devSslConfig.ca }
|
|
864
|
+
: null
|
|
865
|
+
|
|
866
|
+
let stopped = false
|
|
867
|
+
const procs: import('bun').Subprocess[] = []
|
|
868
|
+
function signalWorkers(sig: NodeJS.Signals): void {
|
|
869
|
+
for (const p of procs) {
|
|
870
|
+
try { p.kill(sig) }
|
|
871
|
+
catch { /* already gone */ }
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/** Republish certs then tell workers to reload. */
|
|
875
|
+
async function publishSni(entries: SniEntry[]): Promise<void> {
|
|
876
|
+
await writeClusterSni(rpxDir, entries, dev)
|
|
877
|
+
signalWorkers('SIGHUP')
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// On-demand TLS lives on the coordinator (it owns :80 + ACME). New certs are
|
|
881
|
+
// republished to the workers.
|
|
882
|
+
const onDemandCfg = opts.onDemandTls
|
|
883
|
+
const onDemand: OnDemandCertManager | null = onDemandCfg?.enabled
|
|
884
|
+
? new OnDemandCertManager({
|
|
885
|
+
config: onDemandCfg,
|
|
886
|
+
certsDir: onDemandCfg.certsDir ?? opts.productionCerts?.certsDir ?? path.join(rpxDir, 'on-demand-certs'),
|
|
887
|
+
initial: sniTls,
|
|
888
|
+
verbose,
|
|
889
|
+
onCertAdded: entries => { void publishSni(entries) },
|
|
890
|
+
})
|
|
891
|
+
: null
|
|
892
|
+
|
|
893
|
+
await writeClusterSni(rpxDir, onDemand ? onDemand.sniEntries() : sniTls, dev)
|
|
894
|
+
|
|
895
|
+
// DNS + hosts + registry GC (workers handle routing themselves).
|
|
896
|
+
const initialEntries = await readAll(registryDir, verbose)
|
|
897
|
+
await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
|
|
898
|
+
debugLog('daemon', `DNS reconcile on start failed: ${err}`, verbose)
|
|
899
|
+
})
|
|
900
|
+
await syncDevelopmentDnsFromRegistry(initialEntries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
|
|
901
|
+
debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
|
|
902
|
+
})
|
|
903
|
+
await gcStaleEntries(registryDir, verbose).catch(() => {})
|
|
904
|
+
const watcher = watchRegistry(
|
|
905
|
+
(entries) => {
|
|
906
|
+
syncDevelopmentDnsFromRegistry(entries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
|
|
907
|
+
debugLog('daemon', `DNS sync on registry change failed: ${err}`, verbose)
|
|
908
|
+
})
|
|
909
|
+
},
|
|
910
|
+
{ dir: registryDir, verbose },
|
|
911
|
+
)
|
|
912
|
+
const gcInterval = setInterval(() => {
|
|
913
|
+
gcStaleEntries(registryDir, verbose).catch(() => {})
|
|
914
|
+
}, gcIntervalMs)
|
|
915
|
+
gcInterval.unref?.()
|
|
916
|
+
|
|
917
|
+
// :80 — ACME http-01 challenges + HTTP→HTTPS redirect (kicks off on-demand).
|
|
918
|
+
let httpServer: ReturnType<typeof Bun.serve> | null = null
|
|
919
|
+
if (httpPort > 0) {
|
|
920
|
+
httpServer = Bun.serve({
|
|
921
|
+
port: httpPort,
|
|
922
|
+
hostname,
|
|
923
|
+
fetch(req: Request) {
|
|
924
|
+
return handleHttpRedirect(req, onDemand)
|
|
925
|
+
},
|
|
926
|
+
error() {
|
|
927
|
+
return new Response('Bad Request', { status: 400 })
|
|
928
|
+
},
|
|
929
|
+
})
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Spawn (and keep alive) the workers.
|
|
933
|
+
function spawnWorker(): void {
|
|
934
|
+
if (stopped)
|
|
935
|
+
return
|
|
936
|
+
const env: Record<string, string> = {
|
|
937
|
+
...process.env as Record<string, string>,
|
|
938
|
+
RPX_DAEMON_WORKER: '1',
|
|
939
|
+
RPX_WORKERS: '1', // the child must not recurse into coordinator mode
|
|
940
|
+
RPX_WORKER_RPXDIR: rpxDir,
|
|
941
|
+
RPX_WORKER_REGISTRYDIR: registryDir,
|
|
942
|
+
RPX_WORKER_HTTPSPORT: String(httpsPort),
|
|
943
|
+
RPX_WORKER_HOSTNAME: hostname,
|
|
944
|
+
RPX_WORKER_VERBOSE: verbose ? '1' : '0',
|
|
945
|
+
}
|
|
946
|
+
const proc = Bun.spawn(workerSpawnCommand(), {
|
|
947
|
+
env,
|
|
948
|
+
stdout: 'inherit',
|
|
949
|
+
stderr: 'inherit',
|
|
950
|
+
stdin: 'ignore',
|
|
951
|
+
onExit(_proc, code) {
|
|
952
|
+
if (!stopped) {
|
|
953
|
+
debugLog('daemon', `worker exited (code ${code}); respawning`, verbose)
|
|
954
|
+
spawnWorker()
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
})
|
|
958
|
+
procs.push(proc)
|
|
959
|
+
}
|
|
960
|
+
for (let i = 0; i < workers; i++)
|
|
961
|
+
spawnWorker()
|
|
962
|
+
|
|
963
|
+
if (verbose) {
|
|
964
|
+
log.success(`rpx coordinator listening on https://${hostname}:${httpsPort} via ${workers} worker(s)${httpServer ? ` (http→https on :${httpPort})` : ''}`)
|
|
965
|
+
log.info(`pid file: ${pidPath}`)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
let resolveDone!: () => void
|
|
969
|
+
const done = new Promise<void>((r) => { resolveDone = r })
|
|
970
|
+
async function stop(): Promise<void> {
|
|
971
|
+
if (stopped)
|
|
972
|
+
return done
|
|
973
|
+
stopped = true
|
|
974
|
+
clearInterval(gcInterval)
|
|
975
|
+
watcher.close()
|
|
976
|
+
httpServer?.stop(false)
|
|
977
|
+
signalWorkers('SIGTERM')
|
|
978
|
+
await Promise.race([
|
|
979
|
+
Promise.all(procs.map(p => p.exited)),
|
|
980
|
+
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
981
|
+
])
|
|
982
|
+
signalWorkers('SIGKILL')
|
|
983
|
+
await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
|
|
984
|
+
debugLog('daemon', `DNS teardown failed: ${err}`, verbose)
|
|
985
|
+
})
|
|
986
|
+
await releaseDaemonLock(rpxDir)
|
|
987
|
+
await fsp.unlink(clusterSniPath(rpxDir)).catch(() => {})
|
|
988
|
+
if (verbose)
|
|
989
|
+
log.info('rpx coordinator stopped')
|
|
990
|
+
resolveDone()
|
|
991
|
+
return done
|
|
992
|
+
}
|
|
993
|
+
const onSignal = (sig: NodeJS.Signals): void => {
|
|
994
|
+
debugLog('daemon', `coordinator received ${sig}, shutting down`, verbose)
|
|
995
|
+
stop().catch(() => {})
|
|
996
|
+
}
|
|
997
|
+
process.once('SIGINT', onSignal)
|
|
998
|
+
process.once('SIGTERM', onSignal)
|
|
999
|
+
|
|
1000
|
+
return {
|
|
1001
|
+
stop,
|
|
1002
|
+
done,
|
|
1003
|
+
httpsPort,
|
|
1004
|
+
httpPort,
|
|
1005
|
+
pidPath,
|
|
1006
|
+
ensureCert: (host: string) => (onDemand ? onDemand.ensureCert(host) : Promise.resolve(false)),
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
587
1010
|
export interface EnsureDaemonOptions {
|
|
588
1011
|
/** Override `~/.stacks/rpx`. */
|
|
589
1012
|
rpxDir?: string
|