@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/rpx",
3
3
  "type": "module",
4
- "version": "0.11.13",
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.6"
72
+ "@stacksjs/tlsx": "^0.13.7"
73
73
  },
74
74
  "devDependencies": {
75
75
  "bunfig": "^0.15.6",
@@ -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
- from: string
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
- log.success(`https://${p.to} → ${p.from}`)
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 fromUrl = new URL(entry.from.startsWith('http') ? entry.from : `http://${entry.from}`)
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: entry.cleanUrls ?? false,
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.get(host)
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
- const sslConfig = await bootstrapTls(opts, registryDir)
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 httpsServer = Bun.serve({
346
- port: httpsPort,
347
- hostname,
348
- tls: {
349
- key: sslConfig.key,
350
- cert: sslConfig.cert,
351
- ca: sslConfig.ca,
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
- fetch: createProxyFetchHandler(getRoute, verbose),
356
- error(err: Error) {
357
- debugLog('daemon', `https server error: ${err}`, verbose)
358
- return new Response(`Server Error: ${err.message}`, { status: 500 })
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'
@@ -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
+ }