@stacksjs/rpx 0.11.13 → 0.11.15
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 +178 -174
- package/dist/chunk-0zdj72ps.js +161 -0
- package/dist/{chunk-zs1tyy8z.js → chunk-hf6e07v4.js} +1 -1
- package/dist/{chunk-747af2w4.js → chunk-kv17r01q.js} +1 -1
- package/dist/chunk-pjwm8py7.js +1 -0
- package/dist/daemon-runner.d.ts +3 -2
- package/dist/daemon.d.ts +7 -1
- package/dist/host-match.d.ts +23 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +6 -6
- package/dist/on-demand.d.ts +40 -0
- package/dist/proxy-handler.d.ts +18 -3
- package/dist/registry.d.ts +3 -2
- package/dist/sni.d.ts +20 -0
- package/dist/static-files.d.ts +46 -0
- package/dist/types.d.ts +64 -1
- package/package.json +2 -2
- package/src/daemon-runner.ts +12 -4
- package/src/daemon.ts +154 -21
- package/src/host-match.ts +52 -0
- package/src/index.ts +19 -2
- package/src/on-demand.ts +264 -0
- package/src/proxy-handler.ts +184 -21
- package/src/registry.ts +11 -3
- package/src/sni.ts +93 -0
- package/src/start.ts +66 -19
- package/src/static-files.ts +201 -0
- package/src/types.ts +138 -1
- package/dist/chunk-kbnzcycw.js +0 -1
- package/dist/chunk-pncxrxde.js +0 -157
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/rpx",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.15",
|
|
5
5
|
"description": "A modern and smart reverse proxy.",
|
|
6
6
|
"author": "Chris Breuer <chris@stacksjs.org>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
71
|
"@stacksjs/clapp": "^0.2.10",
|
|
72
|
-
"@stacksjs/tlsx": "^0.13.
|
|
72
|
+
"@stacksjs/tlsx": "^0.13.7"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"bunfig": "^0.15.6",
|
package/src/daemon-runner.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The daemon's PID-GC reaps anything we miss if this process dies `kill -9`.
|
|
12
12
|
*/
|
|
13
|
-
import type { PathRewrite } from './types'
|
|
13
|
+
import type { PathRewrite, StaticRouteConfig } from './types'
|
|
14
14
|
import * as fs from 'node:fs'
|
|
15
15
|
import * as path from 'node:path'
|
|
16
16
|
import * as process from 'node:process'
|
|
@@ -21,11 +21,14 @@ import { debugLog } from './utils'
|
|
|
21
21
|
|
|
22
22
|
export interface DaemonRunnerProxy {
|
|
23
23
|
id?: string
|
|
24
|
-
|
|
24
|
+
/** Upstream `host:port`. Optional when `static` is set. */
|
|
25
|
+
from?: string
|
|
25
26
|
to: string
|
|
26
27
|
cleanUrls?: boolean
|
|
27
28
|
changeOrigin?: boolean
|
|
28
29
|
pathRewrites?: PathRewrite[]
|
|
30
|
+
/** Serve a local directory for this route instead of proxying. */
|
|
31
|
+
static?: string | StaticRouteConfig
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export interface DaemonRunnerOptions {
|
|
@@ -100,6 +103,7 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
|
|
|
100
103
|
cleanUrls: p.cleanUrls,
|
|
101
104
|
changeOrigin: p.changeOrigin,
|
|
102
105
|
pathRewrites: p.pathRewrites,
|
|
106
|
+
static: p.static,
|
|
103
107
|
}, registryDir, verbose)
|
|
104
108
|
}
|
|
105
109
|
|
|
@@ -111,8 +115,12 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
|
|
|
111
115
|
spawnEnv: opts.spawnEnv,
|
|
112
116
|
})
|
|
113
117
|
|
|
114
|
-
for (const p of resolved)
|
|
115
|
-
|
|
118
|
+
for (const p of resolved) {
|
|
119
|
+
const target = p.static
|
|
120
|
+
? `static ${typeof p.static === 'string' ? p.static : p.static.dir}`
|
|
121
|
+
: p.from
|
|
122
|
+
log.success(`https://${p.to} → ${target}`)
|
|
123
|
+
}
|
|
116
124
|
log.info(`(via rpx daemon pid=${result.pid}; \`rpx daemon:status\` to inspect)`)
|
|
117
125
|
|
|
118
126
|
if (opts.detached)
|
package/src/daemon.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* paths are reachable without touching `~/.stacks/rpx` or :443.
|
|
17
17
|
*/
|
|
18
18
|
/* eslint-disable no-console */
|
|
19
|
-
import type { ProxyOptions, SSLConfig, TlsOption } from './types'
|
|
20
|
-
import type { ProxyRoute } from './proxy-handler'
|
|
19
|
+
import type { OnDemandTlsConfig, ProductionTlsConfig, ProxyOptions, SSLConfig, TlsOption } from './types'
|
|
20
|
+
import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
|
|
21
21
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
22
22
|
import * as fsp from 'node:fs/promises'
|
|
23
23
|
import { homedir } from 'node:os'
|
|
@@ -25,7 +25,11 @@ import * as path from 'node:path'
|
|
|
25
25
|
import * as process from 'node:process'
|
|
26
26
|
import { log } from './logger'
|
|
27
27
|
import { checkExistingCertificates, generateCertificate } from './https'
|
|
28
|
-
import { createProxyFetchHandler } from './proxy-handler'
|
|
28
|
+
import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
|
|
29
|
+
import { matchHost } from './host-match'
|
|
30
|
+
import { buildSniTlsConfig } from './sni'
|
|
31
|
+
import { OnDemandCertManager } from './on-demand'
|
|
32
|
+
import { resolveStaticRoute } from './static-files'
|
|
29
33
|
import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
|
|
30
34
|
import type { RegistryEntry } from './registry'
|
|
31
35
|
import {
|
|
@@ -49,6 +53,18 @@ export interface DaemonOptions {
|
|
|
49
53
|
hostname?: string
|
|
50
54
|
/** TLS bootstrap options forwarded to httpsConfig. */
|
|
51
55
|
https?: TlsOption
|
|
56
|
+
/**
|
|
57
|
+
* Production per-domain SNI certs (real PEMs on disk). When usable certs are
|
|
58
|
+
* found, the listener serves them per SNI server name instead of the dev
|
|
59
|
+
* self-signed shared cert.
|
|
60
|
+
*/
|
|
61
|
+
productionCerts?: ProductionTlsConfig
|
|
62
|
+
/**
|
|
63
|
+
* On-demand TLS: lazily issue real certs for approved unknown hosts via ACME
|
|
64
|
+
* http-01 (served from this daemon's `:80` listener). Opt-in via `enabled`.
|
|
65
|
+
* Seeded with the `productionCerts`/`certsDir` certs already on disk.
|
|
66
|
+
*/
|
|
67
|
+
onDemandTls?: OnDemandTlsConfig
|
|
52
68
|
/** PID-GC interval in ms. Defaults to 5000. */
|
|
53
69
|
gcIntervalMs?: number
|
|
54
70
|
}
|
|
@@ -61,6 +77,13 @@ export interface DaemonHandle {
|
|
|
61
77
|
httpsPort: number
|
|
62
78
|
httpPort: number
|
|
63
79
|
pidPath: string
|
|
80
|
+
/**
|
|
81
|
+
* Pre-warm an on-demand cert for `host` (issue it now if approved & missing,
|
|
82
|
+
* rebuilding the `:443` listener). Resolves `true` if a cert is available
|
|
83
|
+
* afterwards. No-op resolving `false` when on-demand TLS isn't enabled. Lets a
|
|
84
|
+
* tunnel server warm a subdomain's cert at registration time.
|
|
85
|
+
*/
|
|
86
|
+
ensureCert: (host: string) => Promise<boolean>
|
|
64
87
|
}
|
|
65
88
|
|
|
66
89
|
const DEFAULT_GC_INTERVAL_MS = 5000
|
|
@@ -148,10 +171,18 @@ export async function releaseDaemonLock(rpxDir: string = getDaemonRpxDir()): Pro
|
|
|
148
171
|
* fetch handler. The entry's `from` is normalized to `host:port`.
|
|
149
172
|
*/
|
|
150
173
|
function entryToRoute(entry: RegistryEntry): ProxyRoute {
|
|
151
|
-
const
|
|
174
|
+
const cleanUrls = entry.cleanUrls ?? false
|
|
175
|
+
if (entry.static) {
|
|
176
|
+
return {
|
|
177
|
+
static: resolveStaticRoute(entry.static, cleanUrls),
|
|
178
|
+
cleanUrls,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const from = entry.from ?? 'localhost:1'
|
|
182
|
+
const fromUrl = new URL(from.startsWith('http') ? from : `http://${from}`)
|
|
152
183
|
return {
|
|
153
184
|
sourceHost: fromUrl.host,
|
|
154
|
-
cleanUrls
|
|
185
|
+
cleanUrls,
|
|
155
186
|
changeOrigin: entry.changeOrigin ?? false,
|
|
156
187
|
pathRewrites: entry.pathRewrites,
|
|
157
188
|
}
|
|
@@ -273,6 +304,9 @@ async function elevateDaemonToRoot(
|
|
|
273
304
|
try { process.kill(pid, 'SIGTERM') }
|
|
274
305
|
catch { /* EPERM — root-owned shared daemon */ }
|
|
275
306
|
},
|
|
307
|
+
// On-demand issuance runs inside the elevated child's own runDaemon
|
|
308
|
+
// handle; this caller-side stub can't reach it directly.
|
|
309
|
+
ensureCert: () => Promise.resolve(false),
|
|
276
310
|
}
|
|
277
311
|
}
|
|
278
312
|
// sudo exits fast when auth fails; while the daemon runs it stays alive.
|
|
@@ -295,6 +329,9 @@ async function elevateDaemonToRoot(
|
|
|
295
329
|
* listeners are bound and the initial routing table is populated. Use
|
|
296
330
|
* `handle.done` for the lifetime promise.
|
|
297
331
|
*/
|
|
332
|
+
// `opts` IS used throughout; pickier's no-unused-vars mis-fires on this fn after
|
|
333
|
+
// the on-demand serve refactor (its --fix would wrongly rename to `_opts`).
|
|
334
|
+
// eslint-disable-next-line pickier/no-unused-vars
|
|
298
335
|
export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {
|
|
299
336
|
const verbose = opts.verbose ?? false
|
|
300
337
|
const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
|
|
@@ -315,8 +352,10 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
315
352
|
const pidPath = await acquireDaemonLock(rpxDir)
|
|
316
353
|
|
|
317
354
|
// Module-scoped state so the watcher and fetch handler share one routing view.
|
|
355
|
+
// Routing table keyed by host pattern. Lookup prefers an exact match, then
|
|
356
|
+
// the most-specific `*.suffix` wildcard (see `matchHost`).
|
|
318
357
|
let routingTable = new Map<string, ProxyRoute>()
|
|
319
|
-
const getRoute = (host: string): ProxyRoute | undefined => routingTable
|
|
358
|
+
const getRoute = (host: string): ProxyRoute | undefined => matchHost(routingTable, host)
|
|
320
359
|
|
|
321
360
|
function rebuild(entries: RegistryEntry[]): void {
|
|
322
361
|
const next = new Map<string, ProxyRoute>()
|
|
@@ -340,24 +379,101 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
340
379
|
debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
|
|
341
380
|
})
|
|
342
381
|
|
|
343
|
-
|
|
382
|
+
// Production per-domain SNI: serve real PEM certs (e.g. Let's Encrypt) keyed
|
|
383
|
+
// by server name on the one listener. Falls back to the dev shared cert when
|
|
384
|
+
// no usable production certs are configured.
|
|
385
|
+
let sniTls: Array<{ serverName: string, cert: string, key: string }> = []
|
|
386
|
+
if (opts.productionCerts) {
|
|
387
|
+
sniTls = await buildSniTlsConfig(opts.productionCerts, verbose)
|
|
388
|
+
if (verbose && sniTls.length > 0)
|
|
389
|
+
log.info(`SNI: serving ${sniTls.length} real cert(s): ${sniTls.map(e => e.serverName).join(', ')}`)
|
|
390
|
+
}
|
|
344
391
|
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
392
|
+
const fetchHandler = createProxyFetchHandler(getRoute, verbose)
|
|
393
|
+
const wsHandler = createProxyWebSocketHandler(verbose)
|
|
394
|
+
|
|
395
|
+
// Bootstrap the dev shared cert once when there's no real SNI set, so a single
|
|
396
|
+
// SNI listener with on-demand can still answer hosts that aren't covered yet.
|
|
397
|
+
let devSslConfig: SSLConfig | null = null
|
|
398
|
+
if (sniTls.length === 0)
|
|
399
|
+
devSslConfig = await bootstrapTls(opts, registryDir)
|
|
400
|
+
|
|
401
|
+
// On-demand TLS manager (opt-in). Holds the live SNI set; lazily issues real
|
|
402
|
+
// certs for approved unknown hosts via ACME http-01 served from our :80
|
|
403
|
+
// listener (Bun can't issue at handshake time — see on-demand.ts header).
|
|
404
|
+
const onDemandCfg = opts.onDemandTls
|
|
405
|
+
const onDemand: OnDemandCertManager | null = onDemandCfg?.enabled
|
|
406
|
+
? new OnDemandCertManager({
|
|
407
|
+
config: onDemandCfg,
|
|
408
|
+
certsDir: onDemandCfg.certsDir ?? opts.productionCerts?.certsDir ?? path.join(rpxDir, 'on-demand-certs'),
|
|
409
|
+
initial: sniTls,
|
|
410
|
+
verbose,
|
|
411
|
+
// A new cert was issued/adopted — rebuild :443 with the augmented set.
|
|
412
|
+
onCertAdded: (entries) => { void rebuildTls(entries) },
|
|
413
|
+
})
|
|
414
|
+
: null
|
|
415
|
+
|
|
416
|
+
/** Build the TLS option for Bun.serve from the current SNI set (or dev cert). */
|
|
417
|
+
function tlsFor(entries: Array<{ serverName: string, cert: string, key: string }>): unknown {
|
|
418
|
+
if (entries.length > 0)
|
|
419
|
+
return entries.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
|
|
420
|
+
// No real certs: fall back to the dev self-signed shared cert.
|
|
421
|
+
return {
|
|
422
|
+
key: devSslConfig!.key,
|
|
423
|
+
cert: devSslConfig!.cert,
|
|
424
|
+
ca: devSslConfig!.ca,
|
|
352
425
|
requestCert: false,
|
|
353
426
|
rejectUnauthorized: false,
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** (Re)create the :443 listener. Factored so on-demand can rebuild it. */
|
|
431
|
+
function serveHttps(entries: Array<{ serverName: string, cert: string, key: string }>): ReturnType<typeof Bun.serve> {
|
|
432
|
+
return Bun.serve({
|
|
433
|
+
port: httpsPort,
|
|
434
|
+
hostname,
|
|
435
|
+
tls: tlsFor(entries) as any,
|
|
436
|
+
fetch(req: Request, server: unknown) {
|
|
437
|
+
return fetchHandler(req, server as ProxyServerLike)
|
|
438
|
+
},
|
|
439
|
+
websocket: wsHandler,
|
|
440
|
+
error(err: Error) {
|
|
441
|
+
debugLog('daemon', `https server error: ${err}`, verbose)
|
|
442
|
+
return new Response(`Server Error: ${err.message}`, { status: 500 })
|
|
443
|
+
},
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let httpsServer = serveHttps(onDemand ? onDemand.sniEntries() : sniTls)
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Bun has no working SNICallback and `server.reload({ tls })` does not update
|
|
451
|
+
* certs at runtime (verified Bun 1.3.14/1.4.0). So to serve a freshly-issued
|
|
452
|
+
* cert we tear the old listener down and re-bind with the augmented SNI set.
|
|
453
|
+
* The rebind is sub-second; if the OS hasn't freed the port yet we retry on a
|
|
454
|
+
* short async backoff. In-flight requests on the old listener drain
|
|
455
|
+
* (`stop(false)`). Only ever invoked from the (async) issuance callback.
|
|
456
|
+
*/
|
|
457
|
+
async function rebuildTls(entries: Array<{ serverName: string, cert: string, key: string }>): Promise<void> {
|
|
458
|
+
if (stopped)
|
|
459
|
+
return
|
|
460
|
+
debugLog('daemon', `rebuilding :443 with ${entries.length} SNI cert(s)`, verbose)
|
|
461
|
+
httpsServer.stop(false)
|
|
462
|
+
let lastErr: unknown
|
|
463
|
+
for (let attempt = 0; attempt < 20 && !stopped; attempt++) {
|
|
464
|
+
try {
|
|
465
|
+
httpsServer = serveHttps(entries)
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
// EADDRINUSE while the old socket releases — back off briefly, retry.
|
|
470
|
+
lastErr = err
|
|
471
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Could not rebind: the old listener is already down. Surface the failure.
|
|
475
|
+
log.error(`rpx: failed to rebuild :443 after issuing cert: ${(lastErr as Error)?.message}`)
|
|
476
|
+
}
|
|
361
477
|
|
|
362
478
|
let httpServer: ReturnType<typeof Bun.serve> | null = null
|
|
363
479
|
if (httpPort > 0) {
|
|
@@ -367,6 +483,22 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
367
483
|
fetch(req: Request) {
|
|
368
484
|
const u = new URL(req.url)
|
|
369
485
|
const host = (req.headers.get('host') ?? u.hostname).split(':')[0]
|
|
486
|
+
|
|
487
|
+
// Serve ACME http-01 challenges for in-flight on-demand issuances.
|
|
488
|
+
if (onDemand && u.pathname.startsWith('/.well-known/acme-challenge/')) {
|
|
489
|
+
const keyAuth = onDemand.challengeStore.handlePath(u.pathname)
|
|
490
|
+
if (keyAuth !== undefined)
|
|
491
|
+
return new Response(keyAuth, { status: 200, headers: { 'content-type': 'text/plain' } })
|
|
492
|
+
return new Response('challenge not found', { status: 404 })
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// First plaintext hit for an approved-but-uncovered host: kick off
|
|
496
|
+
// issuance so the cert exists for the subsequent HTTPS request. We don't
|
|
497
|
+
// block the redirect on it (the browser retries over HTTPS anyway).
|
|
498
|
+
if (onDemand && !onDemand.hasCert(host)) {
|
|
499
|
+
onDemand.ensureCert(host).catch(() => {})
|
|
500
|
+
}
|
|
501
|
+
|
|
370
502
|
return new Response(null, {
|
|
371
503
|
status: 301,
|
|
372
504
|
headers: { Location: `https://${host}${u.pathname}${u.search}` },
|
|
@@ -441,6 +573,7 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
441
573
|
httpsPort: typeof httpsServer.port === 'number' ? httpsServer.port : httpsPort,
|
|
442
574
|
httpPort: httpServer && typeof httpServer.port === 'number' ? httpServer.port : httpPort,
|
|
443
575
|
pidPath,
|
|
576
|
+
ensureCert: (host: string) => (onDemand ? onDemand.ensureCert(host) : Promise.resolve(false)),
|
|
444
577
|
}
|
|
445
578
|
}
|
|
446
579
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-based route matching with wildcard support.
|
|
3
|
+
*
|
|
4
|
+
* The routing table is keyed by host pattern. A pattern is either an exact
|
|
5
|
+
* hostname (`api.example.com`) or a wildcard (`*.example.com`). Lookup prefers
|
|
6
|
+
* an exact match, then the most-specific (deepest-suffix) wildcard.
|
|
7
|
+
*
|
|
8
|
+
* Kept dependency-free and pure so it's reusable from both the daemon and the
|
|
9
|
+
* in-process multi-proxy path, and trivially unit-testable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function isWildcardPattern(pattern: string): boolean {
|
|
13
|
+
return pattern.startsWith('*.')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* True if `hostname` matches the wildcard `pattern` (`*.suffix`). A wildcard
|
|
18
|
+
* matches exactly one or more leading labels — `*.example.com` matches
|
|
19
|
+
* `a.example.com` and `a.b.example.com`, but NOT the bare apex `example.com`.
|
|
20
|
+
*/
|
|
21
|
+
export function matchesWildcard(hostname: string, pattern: string): boolean {
|
|
22
|
+
if (!isWildcardPattern(pattern))
|
|
23
|
+
return false
|
|
24
|
+
const suffix = pattern.slice(1) // '*.example.com' → '.example.com'
|
|
25
|
+
return hostname.length > suffix.length && hostname.endsWith(suffix)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Find the route value for `hostname` in a host-keyed map. Exact match wins;
|
|
30
|
+
* otherwise the matching wildcard with the longest (most-specific) suffix wins.
|
|
31
|
+
* Returns `undefined` when nothing matches.
|
|
32
|
+
*/
|
|
33
|
+
export function matchHost<T>(table: Map<string, T>, hostname: string): T | undefined {
|
|
34
|
+
const exact = table.get(hostname)
|
|
35
|
+
if (exact !== undefined)
|
|
36
|
+
return exact
|
|
37
|
+
|
|
38
|
+
let best: T | undefined
|
|
39
|
+
let bestLen = -1
|
|
40
|
+
for (const [pattern, value] of table) {
|
|
41
|
+
if (!isWildcardPattern(pattern))
|
|
42
|
+
continue
|
|
43
|
+
if (matchesWildcard(hostname, pattern)) {
|
|
44
|
+
const len = pattern.length - 1 // length of the matched suffix
|
|
45
|
+
if (len > bestLen) {
|
|
46
|
+
bestLen = len
|
|
47
|
+
best = value
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return best
|
|
52
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -112,8 +112,25 @@ export type {
|
|
|
112
112
|
StopDaemonResult,
|
|
113
113
|
} from './daemon'
|
|
114
114
|
|
|
115
|
-
export { createProxyFetchHandler } from './proxy-handler'
|
|
116
|
-
export type { GetRoute, ProxyFetchHandler, ProxyRoute } from './proxy-handler'
|
|
115
|
+
export { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
|
|
116
|
+
export type { GetRoute, ProxyFetchHandler, ProxyRoute, ProxyServer } from './proxy-handler'
|
|
117
|
+
|
|
118
|
+
export { isWildcardPattern, matchesWildcard, matchHost } from './host-match'
|
|
119
|
+
|
|
120
|
+
export {
|
|
121
|
+
contentTypeFor,
|
|
122
|
+
resolveStaticFile,
|
|
123
|
+
resolveStaticRoute,
|
|
124
|
+
safeRelativePath,
|
|
125
|
+
serveStaticFile,
|
|
126
|
+
} from './static-files'
|
|
127
|
+
export type { ResolvedStaticRoute, StaticResolution } from './static-files'
|
|
128
|
+
|
|
129
|
+
export { buildSniTlsConfig, serverNameFromCertFilename } from './sni'
|
|
130
|
+
export type { SniTlsEntry } from './sni'
|
|
131
|
+
|
|
132
|
+
export { isLikelyHostname, matchesAllowedSuffix, OnDemandCertManager } from './on-demand'
|
|
133
|
+
export type { CertIssuer, OnDemandCertManagerOptions } from './on-demand'
|
|
117
134
|
|
|
118
135
|
export { deriveIdFromTarget, runViaDaemon } from './daemon-runner'
|
|
119
136
|
export type { DaemonRunnerOptions, DaemonRunnerProxy } from './daemon-runner'
|
package/src/on-demand.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand TLS for rpx: issue a real (Let's Encrypt, http-01) certificate for
|
|
3
|
+
* an unknown host the first time it's needed, gated by an `ask` callback and/or
|
|
4
|
+
* an `allowedSuffixes` allowlist.
|
|
5
|
+
*
|
|
6
|
+
* ## The Bun limitation this works around (verified on Bun 1.3.14 + 1.4.0)
|
|
7
|
+
*
|
|
8
|
+
* Bun.serve has **no working `SNICallback`**, and `server.reload({ tls })` does
|
|
9
|
+
* **NOT** update certificates at runtime. So rpx cannot mint a cert *during* the
|
|
10
|
+
* TLS handshake (the way Caddy's on-demand TLS does). Instead this manager
|
|
11
|
+
* implements on-demand as **ask-gated issuance + listener recreate**:
|
|
12
|
+
*
|
|
13
|
+
* - rpx serves the ACME `http-01` challenge from its own `:80` listener (same
|
|
14
|
+
* process, so the challenge token is reachable the instant we register it).
|
|
15
|
+
* - issuance is triggered before the HTTPS request — either reactively from
|
|
16
|
+
* the `:80` handler (first plaintext hit for the host), or programmatically
|
|
17
|
+
* via {@link OnDemandCertManager.ensureCert} (e.g. a tunnel server
|
|
18
|
+
* pre-warming a subdomain's cert at registration time).
|
|
19
|
+
* - once a cert is issued it's written to `certsDir` and added to the live SNI
|
|
20
|
+
* set; the manager then asks its host to rebuild the `:443` listener so the
|
|
21
|
+
* new cert is actually served (a sub-second `server.stop()` + re-`Bun.serve`).
|
|
22
|
+
*
|
|
23
|
+
* Concurrency: per-host in-flight de-dupe means N concurrent `ensureCert(host)`
|
|
24
|
+
* calls drive exactly one ACME order. Failures are logged and negatively cached
|
|
25
|
+
* for a short window so we don't hammer Let's Encrypt (which is rate-limited).
|
|
26
|
+
*/
|
|
27
|
+
import type { Http01Store, ObtainCertificateOptions, ObtainCertificateResult } from '@stacksjs/tlsx'
|
|
28
|
+
import type { OnDemandTlsConfig } from './types'
|
|
29
|
+
import type { SniTlsEntry } from './sni'
|
|
30
|
+
import * as fsp from 'node:fs/promises'
|
|
31
|
+
import * as path from 'node:path'
|
|
32
|
+
import { defaultHttp01Store, obtainCertificate } from '@stacksjs/tlsx'
|
|
33
|
+
import { debugLog } from './utils'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The issuance function the manager calls. Defaults to tlsx's
|
|
37
|
+
* {@link obtainCertificate}; tests inject a stub so the suite never touches
|
|
38
|
+
* Let's Encrypt.
|
|
39
|
+
*/
|
|
40
|
+
export type CertIssuer = (options: ObtainCertificateOptions) => Promise<ObtainCertificateResult>
|
|
41
|
+
|
|
42
|
+
export interface OnDemandCertManagerOptions {
|
|
43
|
+
/** Resolved on-demand config (already merged with defaults). */
|
|
44
|
+
config: OnDemandTlsConfig
|
|
45
|
+
/** Where issued PEMs are written / read. Required (resolved by the caller). */
|
|
46
|
+
certsDir: string
|
|
47
|
+
/** Initial SNI set to seed from (e.g. productionCerts already on disk). */
|
|
48
|
+
initial?: SniTlsEntry[]
|
|
49
|
+
/**
|
|
50
|
+
* Called after a new cert is added to the SNI set so the host can rebuild its
|
|
51
|
+
* `:443` listener (Bun can't hot-update tls — see file header).
|
|
52
|
+
*/
|
|
53
|
+
onCertAdded?: (entries: SniTlsEntry[]) => void | Promise<void>
|
|
54
|
+
/** http-01 challenge store rpx's `:80` listener serves from. */
|
|
55
|
+
http01Store?: Http01Store
|
|
56
|
+
/** Inject the issuer (tests stub this). Defaults to tlsx `obtainCertificate`. */
|
|
57
|
+
issuer?: CertIssuer
|
|
58
|
+
verbose?: boolean
|
|
59
|
+
/** How long to negatively-cache a failed host before retrying. Default 60s. */
|
|
60
|
+
negativeCacheMs?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_NEGATIVE_CACHE_MS = 60_000
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True if `host` is covered by the `allowedSuffixes` allowlist: it equals a
|
|
67
|
+
* suffix, or is a subdomain of one (`a.example.com` for suffix `example.com`).
|
|
68
|
+
*/
|
|
69
|
+
export function matchesAllowedSuffix(host: string, suffixes: string[] | undefined): boolean {
|
|
70
|
+
if (!suffixes || suffixes.length === 0)
|
|
71
|
+
return false
|
|
72
|
+
return suffixes.some((s) => {
|
|
73
|
+
const suffix = s.startsWith('.') ? s.slice(1) : s
|
|
74
|
+
return host === suffix || host.endsWith(`.${suffix}`)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Strict-ish hostname guard so we never feed junk Host headers into ACME. */
|
|
79
|
+
export function isLikelyHostname(host: string): boolean {
|
|
80
|
+
if (!host || host.length > 253)
|
|
81
|
+
return false
|
|
82
|
+
if (host.includes('/') || host.includes(':') || host.includes(' '))
|
|
83
|
+
return false
|
|
84
|
+
// No wildcards (http-01 can't do them) and must contain a dot (a real FQDN).
|
|
85
|
+
if (host.startsWith('*'))
|
|
86
|
+
return false
|
|
87
|
+
return /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(host)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Holds the live SNI cert set and lazily issues certs for approved hosts.
|
|
92
|
+
*
|
|
93
|
+
* The set is keyed by SNI server name; `ensureCert(host)` is the entry point for
|
|
94
|
+
* both the reactive `:80` path and programmatic pre-warming.
|
|
95
|
+
*/
|
|
96
|
+
export class OnDemandCertManager {
|
|
97
|
+
private readonly config: OnDemandTlsConfig
|
|
98
|
+
private readonly certsDir: string
|
|
99
|
+
private readonly onCertAdded?: (entries: SniTlsEntry[]) => void | Promise<void>
|
|
100
|
+
private readonly http01Store: Http01Store
|
|
101
|
+
private readonly issuer: CertIssuer
|
|
102
|
+
private readonly verbose: boolean
|
|
103
|
+
private readonly negativeCacheMs: number
|
|
104
|
+
|
|
105
|
+
/** Live SNI set, keyed by server name. */
|
|
106
|
+
private readonly certs = new Map<string, SniTlsEntry>()
|
|
107
|
+
/** In-flight issuances, keyed by host — de-dupes concurrent ensureCert calls. */
|
|
108
|
+
private readonly inFlight = new Map<string, Promise<boolean>>()
|
|
109
|
+
/** host → epoch-ms until which we refuse to retry after a failure. */
|
|
110
|
+
private readonly negativeCache = new Map<string, number>()
|
|
111
|
+
|
|
112
|
+
constructor(opts: OnDemandCertManagerOptions) {
|
|
113
|
+
this.config = opts.config
|
|
114
|
+
this.certsDir = opts.certsDir
|
|
115
|
+
this.onCertAdded = opts.onCertAdded
|
|
116
|
+
this.http01Store = opts.http01Store ?? defaultHttp01Store
|
|
117
|
+
this.issuer = opts.issuer ?? obtainCertificate
|
|
118
|
+
this.verbose = opts.verbose ?? false
|
|
119
|
+
this.negativeCacheMs = opts.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS
|
|
120
|
+
for (const e of opts.initial ?? [])
|
|
121
|
+
this.certs.set(e.serverName, e)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The http-01 store rpx's `:80` listener must serve challenge tokens from. */
|
|
125
|
+
get challengeStore(): Http01Store {
|
|
126
|
+
return this.http01Store
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A snapshot of the current SNI set for `Bun.serve({ tls })`. */
|
|
130
|
+
sniEntries(): SniTlsEntry[] {
|
|
131
|
+
return Array.from(this.certs.values())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True if a usable cert for `host` is already loaded in the live set. */
|
|
135
|
+
hasCert(host: string): boolean {
|
|
136
|
+
return this.certs.has(host)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decide whether rpx may issue a cert for `host`. A host is approved when the
|
|
141
|
+
* `allowedSuffixes` allowlist matches OR `ask(host)` resolves truthy. With
|
|
142
|
+
* neither configured, every host is refused (fail-closed, anti-abuse).
|
|
143
|
+
*/
|
|
144
|
+
async isApproved(host: string): Promise<boolean> {
|
|
145
|
+
if (!isLikelyHostname(host))
|
|
146
|
+
return false
|
|
147
|
+
if (matchesAllowedSuffix(host, this.config.allowedSuffixes))
|
|
148
|
+
return true
|
|
149
|
+
if (this.config.ask) {
|
|
150
|
+
try {
|
|
151
|
+
return await this.config.ask(host)
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
debugLog('on-demand', `ask(${host}) threw: ${(err as Error).message}`, this.verbose)
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Ensure a cert exists for `host`, issuing one via ACME http-01 if needed.
|
|
163
|
+
*
|
|
164
|
+
* No-ops (resolves `true`) when a cert is already loaded. Otherwise checks
|
|
165
|
+
* approval, then drives issuance — de-duping concurrent calls for the same
|
|
166
|
+
* host so only one ACME order runs. Resolves `false` when refused or on a
|
|
167
|
+
* negatively-cached failure. Never throws; errors are logged + cached.
|
|
168
|
+
*/
|
|
169
|
+
async ensureCert(host: string): Promise<boolean> {
|
|
170
|
+
if (!this.config.enabled)
|
|
171
|
+
return false
|
|
172
|
+
if (this.certs.has(host))
|
|
173
|
+
return true
|
|
174
|
+
|
|
175
|
+
const inFlight = this.inFlight.get(host)
|
|
176
|
+
if (inFlight)
|
|
177
|
+
return inFlight
|
|
178
|
+
|
|
179
|
+
const until = this.negativeCache.get(host)
|
|
180
|
+
if (until !== undefined && Date.now() < until) {
|
|
181
|
+
debugLog('on-demand', `${host} negatively cached for ${until - Date.now()}ms`, this.verbose)
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const promise = this.issue(host).finally(() => {
|
|
186
|
+
this.inFlight.delete(host)
|
|
187
|
+
})
|
|
188
|
+
this.inFlight.set(host, promise)
|
|
189
|
+
return promise
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async issue(host: string): Promise<boolean> {
|
|
193
|
+
// A concurrent caller may have already loaded it while we were queued.
|
|
194
|
+
if (this.certs.has(host))
|
|
195
|
+
return true
|
|
196
|
+
|
|
197
|
+
// Maybe it's already on disk (issued by a prior run) — adopt without ACME.
|
|
198
|
+
if (await this.loadFromDisk(host))
|
|
199
|
+
return true
|
|
200
|
+
|
|
201
|
+
if (!(await this.isApproved(host))) {
|
|
202
|
+
debugLog('on-demand', `refused issuance for ${host} (not approved)`, this.verbose)
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
debugLog('on-demand', `issuing cert for ${host} (staging=${this.config.staging ?? false})`, this.verbose)
|
|
208
|
+
const result = await this.issuer({
|
|
209
|
+
domains: [host],
|
|
210
|
+
method: 'http-01',
|
|
211
|
+
http01Store: this.http01Store,
|
|
212
|
+
email: this.config.email,
|
|
213
|
+
staging: this.config.staging,
|
|
214
|
+
})
|
|
215
|
+
await this.persist(host, result.fullChainPem, result.keyPem)
|
|
216
|
+
const entry: SniTlsEntry = { serverName: host, cert: result.fullChainPem, key: result.keyPem }
|
|
217
|
+
this.certs.set(host, entry)
|
|
218
|
+
this.negativeCache.delete(host)
|
|
219
|
+
debugLog('on-demand', `issued + installed cert for ${host}`, this.verbose)
|
|
220
|
+
await this.onCertAdded?.(this.sniEntries())
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
this.negativeCache.set(host, Date.now() + this.negativeCacheMs)
|
|
225
|
+
debugLog('on-demand', `issuance for ${host} failed: ${(err as Error).message}`, this.verbose)
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Try to load an already-present `<host>.{crt,key}` pair from `certsDir`. */
|
|
231
|
+
private async loadFromDisk(host: string): Promise<boolean> {
|
|
232
|
+
const { certPath, keyPath } = this.pathsFor(host)
|
|
233
|
+
try {
|
|
234
|
+
const [cert, key] = await Promise.all([
|
|
235
|
+
fsp.readFile(certPath, 'utf8'),
|
|
236
|
+
fsp.readFile(keyPath, 'utf8'),
|
|
237
|
+
])
|
|
238
|
+
const entry: SniTlsEntry = { serverName: host, cert, key }
|
|
239
|
+
this.certs.set(host, entry)
|
|
240
|
+
debugLog('on-demand', `adopted existing on-disk cert for ${host}`, this.verbose)
|
|
241
|
+
await this.onCertAdded?.(this.sniEntries())
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private pathsFor(host: string): { certPath: string, keyPath: string } {
|
|
250
|
+
return {
|
|
251
|
+
certPath: path.join(this.certsDir, `${host}.crt`),
|
|
252
|
+
keyPath: path.join(this.certsDir, `${host}.key`),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async persist(host: string, certPem: string, keyPem: string): Promise<void> {
|
|
257
|
+
await fsp.mkdir(this.certsDir, { recursive: true }).catch(() => {})
|
|
258
|
+
const { certPath, keyPath } = this.pathsFor(host)
|
|
259
|
+
await Promise.all([
|
|
260
|
+
fsp.writeFile(certPath, certPem, 'utf8'),
|
|
261
|
+
fsp.writeFile(keyPath, keyPem, { encoding: 'utf8', mode: 0o600 }),
|
|
262
|
+
])
|
|
263
|
+
}
|
|
264
|
+
}
|