@stacksjs/rpx 0.11.14 → 0.11.16
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 +188 -184
- package/dist/{chunk-5ygwd93k.js → chunk-1sg87m7v.js} +1 -1
- package/dist/{chunk-a0ddh9cv.js → chunk-a0b9f9fs.js} +1 -1
- package/dist/chunk-n3etse5z.js +161 -0
- package/dist/{chunk-3pgh05pc.js → chunk-rbgb5fyg.js} +1 -1
- package/dist/daemon-runner.d.ts +3 -1
- package/dist/daemon.d.ts +6 -1
- package/dist/host-routes.d.ts +43 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +6 -6
- package/dist/on-demand.d.ts +40 -0
- package/dist/proxy-handler.d.ts +14 -1
- package/dist/registry.d.ts +2 -0
- package/dist/types.d.ts +32 -0
- package/package.json +2 -2
- package/src/daemon-runner.ts +14 -3
- package/src/daemon.ts +132 -34
- package/src/host-routes.ts +147 -0
- package/src/index.ts +13 -1
- package/src/on-demand.ts +264 -0
- package/src/proxy-handler.ts +60 -7
- package/src/registry.ts +12 -0
- package/src/start.ts +40 -14
- package/src/types.ts +66 -0
- package/dist/chunk-tx5hnj92.js +0 -157
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
|
+
}
|
package/src/proxy-handler.ts
CHANGED
|
@@ -33,9 +33,35 @@ export interface ProxyRoute {
|
|
|
33
33
|
pathRewrites?: PathRewrite[]
|
|
34
34
|
/** When set, serve files from a local directory instead of proxying. */
|
|
35
35
|
static?: ResolvedStaticRoute
|
|
36
|
+
/**
|
|
37
|
+
* Path prefix this route is mounted under (e.g. `/docs`). Used together with
|
|
38
|
+
* {@link stripBasePathPrefix} to map request paths to the target. `/` (the
|
|
39
|
+
* host default) is a no-op.
|
|
40
|
+
*/
|
|
41
|
+
basePath?: string
|
|
42
|
+
/**
|
|
43
|
+
* Whether to strip {@link basePath} from the request pathname before
|
|
44
|
+
* resolving the target.
|
|
45
|
+
*
|
|
46
|
+
* - Static routes default to `true`: a directory mounted at `/docs` serves
|
|
47
|
+
* its own `index.html` for `/docs` and `<root>/guide` for `/docs/guide`.
|
|
48
|
+
* - Proxy routes default to `false`: most apps own their namespace (an app
|
|
49
|
+
* mounted at `/api` expects to still see `/api/...`), matching rpx's
|
|
50
|
+
* `PathRewrite.stripPrefix` default and nginx `proxy_pass` (no trailing
|
|
51
|
+
* slash) behavior.
|
|
52
|
+
*
|
|
53
|
+
* When unset, the per-transport default above applies.
|
|
54
|
+
*/
|
|
55
|
+
stripBasePathPrefix?: boolean
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a route for an incoming request. `pathname` enables path-based
|
|
60
|
+
* routing within a host (e.g. `/api/*` → app, `/docs*` → static dir). Callers
|
|
61
|
+
* that only route by host can ignore the second argument — it's optional so
|
|
62
|
+
* existing host-only `getRoute` callbacks remain valid.
|
|
63
|
+
*/
|
|
64
|
+
export type GetRoute = (hostname: string, pathname: string) => ProxyRoute | undefined
|
|
39
65
|
|
|
40
66
|
export type ProxyFetchHandler = (req: Request, server?: ProxyServer) => Promise<Response | undefined>
|
|
41
67
|
|
|
@@ -79,6 +105,24 @@ function extractHostname(req: Request): string {
|
|
|
79
105
|
return hostHeader.split(':')[0]
|
|
80
106
|
}
|
|
81
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Strip the route's mount prefix (`basePath`) from a request pathname so a
|
|
110
|
+
* target mounted under `/docs` sees `/` for `/docs` and `/guide` for
|
|
111
|
+
* `/docs/guide`. A `/` (or empty) base strips nothing. The result always keeps
|
|
112
|
+
* a leading `/`.
|
|
113
|
+
*/
|
|
114
|
+
export function stripBasePath(pathname: string, basePath?: string): string {
|
|
115
|
+
if (!basePath || basePath === '/')
|
|
116
|
+
return pathname
|
|
117
|
+
if (pathname === basePath)
|
|
118
|
+
return '/'
|
|
119
|
+
if (pathname.startsWith(`${basePath}/`)) {
|
|
120
|
+
const rest = pathname.slice(basePath.length)
|
|
121
|
+
return rest === '' ? '/' : rest
|
|
122
|
+
}
|
|
123
|
+
return pathname
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
/**
|
|
83
127
|
* Resolve the upstream target (`host` + `path`) for a request against a route,
|
|
84
128
|
* applying any matching path rewrite.
|
|
@@ -86,9 +130,13 @@ function extractHostname(req: Request): string {
|
|
|
86
130
|
function resolveTarget(req: Request, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string, search: string } {
|
|
87
131
|
const url = new URL(req.url)
|
|
88
132
|
let targetHost = route.sourceHost ?? ''
|
|
89
|
-
|
|
133
|
+
// Proxy backends preserve their mount prefix by default (most apps own their
|
|
134
|
+
// `/api` namespace), opting in to stripping via `stripBasePathPrefix`.
|
|
135
|
+
// Explicit `pathRewrites` still apply on top of this.
|
|
136
|
+
const stripBase = route.stripBasePathPrefix ?? false
|
|
137
|
+
let targetPath = stripBase ? stripBasePath(url.pathname, route.basePath) : url.pathname
|
|
90
138
|
|
|
91
|
-
const rewriteMatch = resolvePathRewrite(
|
|
139
|
+
const rewriteMatch = resolvePathRewrite(targetPath, route.pathRewrites)
|
|
92
140
|
if (rewriteMatch) {
|
|
93
141
|
targetHost = rewriteMatch.targetHost
|
|
94
142
|
targetPath = rewriteMatch.targetPath
|
|
@@ -110,15 +158,20 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
|
|
|
110
158
|
const url = new URL(req.url)
|
|
111
159
|
const hostname = extractHostname(req)
|
|
112
160
|
|
|
113
|
-
const route = getRoute(hostname)
|
|
161
|
+
const route = getRoute(hostname, url.pathname)
|
|
114
162
|
if (!route) {
|
|
115
163
|
debugLog('request', `No route found for host: ${hostname}`, verbose)
|
|
116
164
|
return new Response(`No proxy configured for ${hostname}`, { status: 404 })
|
|
117
165
|
}
|
|
118
166
|
|
|
119
|
-
// Static file serving short-circuits everything else.
|
|
120
|
-
|
|
121
|
-
|
|
167
|
+
// Static file serving short-circuits everything else. Strip the route's
|
|
168
|
+
// mount prefix (default for static) so a dir mounted at `/docs` serves its
|
|
169
|
+
// own root for `/docs`.
|
|
170
|
+
if (route.static) {
|
|
171
|
+
const strip = route.stripBasePathPrefix ?? true
|
|
172
|
+
const staticPath = strip ? stripBasePath(url.pathname, route.basePath) : url.pathname
|
|
173
|
+
return serveStaticFile(staticPath, route.static)
|
|
174
|
+
}
|
|
122
175
|
|
|
123
176
|
// WebSocket upgrade: hand the socket to Bun and dial the upstream on open.
|
|
124
177
|
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
package/src/registry.ts
CHANGED
|
@@ -27,6 +27,14 @@ export interface RegistryEntry {
|
|
|
27
27
|
/** Upstream `host:port`. Optional when `static` is set. */
|
|
28
28
|
from?: string
|
|
29
29
|
to: string
|
|
30
|
+
/**
|
|
31
|
+
* Optional path prefix this route owns under the host `to` (e.g. `'/api'`).
|
|
32
|
+
* Enables several routes to share one host, each claiming a different path
|
|
33
|
+
* (`/api` → app, `/docs` → static dir, `/` → public). Omitted (or `'/'`)
|
|
34
|
+
* means the route is the host default and behaves exactly like host-only
|
|
35
|
+
* routing did before path routing existed.
|
|
36
|
+
*/
|
|
37
|
+
path?: string
|
|
30
38
|
/**
|
|
31
39
|
* Optional. PID of the long-running process that owns this entry. When set,
|
|
32
40
|
* the daemon's PID-GC reaps the entry the moment that process dies. Omit
|
|
@@ -96,10 +104,13 @@ function isValidEntry(value: unknown): value is RegistryEntry {
|
|
|
96
104
|
const hasFrom = typeof e.from === 'string' && e.from.length > 0
|
|
97
105
|
const hasStatic = typeof e.static === 'string'
|
|
98
106
|
|| (!!e.static && typeof e.static === 'object' && typeof (e.static as StaticRouteConfig).dir === 'string')
|
|
107
|
+
// path is optional; when present it must be a string.
|
|
108
|
+
const pathOk = e.path === undefined || typeof e.path === 'string'
|
|
99
109
|
return (
|
|
100
110
|
typeof e.id === 'string' && isValidId(e.id)
|
|
101
111
|
&& (hasFrom || hasStatic)
|
|
102
112
|
&& typeof e.to === 'string' && e.to.length > 0
|
|
113
|
+
&& pathOk
|
|
103
114
|
&& pidOk
|
|
104
115
|
&& typeof e.createdAt === 'string'
|
|
105
116
|
)
|
|
@@ -275,6 +286,7 @@ export function watchRegistry(
|
|
|
275
286
|
id: entry.id,
|
|
276
287
|
from: entry.from,
|
|
277
288
|
to: entry.to,
|
|
289
|
+
path: entry.path,
|
|
278
290
|
pid: entry.pid,
|
|
279
291
|
pathRewrites: entry.pathRewrites,
|
|
280
292
|
cleanUrls: entry.cleanUrls,
|
package/src/start.ts
CHANGED
|
@@ -22,7 +22,8 @@ import { DefaultPortManager, findAvailablePort, isPortInUse } from './port-manag
|
|
|
22
22
|
import { ProcessManager } from './process-manager'
|
|
23
23
|
import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
|
|
24
24
|
import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
|
|
25
|
-
import { isWildcardPattern
|
|
25
|
+
import { isWildcardPattern } from './host-match'
|
|
26
|
+
import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
|
|
26
27
|
import { resolveStaticRoute } from './static-files'
|
|
27
28
|
import { debugLog, getSudoPassword, safeStringify } from './utils'
|
|
28
29
|
|
|
@@ -849,6 +850,7 @@ export function startProxy(options: ProxyOption): void {
|
|
|
849
850
|
id: mergedOptions.id,
|
|
850
851
|
from: mergedOptions.from,
|
|
851
852
|
to: mergedOptions.to,
|
|
853
|
+
path: mergedOptions.path,
|
|
852
854
|
cleanUrls: mergedOptions.cleanUrls,
|
|
853
855
|
changeOrigin: mergedOptions.changeOrigin,
|
|
854
856
|
pathRewrites: mergedOptions.pathRewrites,
|
|
@@ -996,6 +998,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
996
998
|
id: p.id,
|
|
997
999
|
from: p.from,
|
|
998
1000
|
to: p.to,
|
|
1001
|
+
path: p.path,
|
|
999
1002
|
cleanUrls: p.cleanUrls ?? mergedOptions.cleanUrls,
|
|
1000
1003
|
changeOrigin: p.changeOrigin ?? mergedOptions.changeOrigin,
|
|
1001
1004
|
pathRewrites: p.pathRewrites,
|
|
@@ -1004,6 +1007,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1004
1007
|
id: mergedOptions.id,
|
|
1005
1008
|
from: mergedOptions.from,
|
|
1006
1009
|
to: mergedOptions.to,
|
|
1010
|
+
path: mergedOptions.path,
|
|
1007
1011
|
cleanUrls: mergedOptions.cleanUrls,
|
|
1008
1012
|
changeOrigin: mergedOptions.changeOrigin,
|
|
1009
1013
|
pathRewrites: mergedOptions.pathRewrites,
|
|
@@ -1242,34 +1246,52 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1242
1246
|
if (sslConfig && proxyOptions.length > 1) {
|
|
1243
1247
|
debugLog('proxies', `Creating shared HTTPS server for ${proxyOptions.length} domains`, verbose)
|
|
1244
1248
|
|
|
1245
|
-
|
|
1249
|
+
// Collect (host, path, route) tuples so several proxies can share one
|
|
1250
|
+
// domain on different paths (e.g. `/api` → app, `/docs` → static dir,
|
|
1251
|
+
// `/` → public). `buildHostRoutes` groups + longest-prefix-sorts them.
|
|
1252
|
+
const routeEntries: Array<{ host: string, path?: string, route: ProxyRoute }> = []
|
|
1253
|
+
const seenDomains = new Set<string>()
|
|
1246
1254
|
|
|
1247
1255
|
for (const option of proxyOptions) {
|
|
1248
1256
|
const domain = option.to || 'rpx.localhost'
|
|
1249
1257
|
const cleanUrls = option.cleanUrls || false
|
|
1258
|
+
const routePath = option.path
|
|
1259
|
+
|
|
1260
|
+
const basePath = normalizePathPrefix(routePath)
|
|
1250
1261
|
|
|
1251
1262
|
// Static-file route: serve a local directory instead of proxying.
|
|
1252
1263
|
if (option.static) {
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1264
|
+
routeEntries.push({
|
|
1265
|
+
host: domain,
|
|
1266
|
+
path: routePath,
|
|
1267
|
+
route: { static: resolveStaticRoute(option.static, cleanUrls), cleanUrls, basePath },
|
|
1256
1268
|
})
|
|
1257
|
-
debugLog('proxies', `Route: ${domain} → static ${typeof option.static === 'string' ? option.static : option.static.dir}`, verbose)
|
|
1269
|
+
debugLog('proxies', `Route: ${domain}${routePath ?? ''} → static ${typeof option.static === 'string' ? option.static : option.static.dir}`, verbose)
|
|
1258
1270
|
}
|
|
1259
1271
|
else {
|
|
1260
1272
|
const fromUrl = new URL(option.from?.startsWith('http') ? option.from : `http://${option.from}`)
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1273
|
+
routeEntries.push({
|
|
1274
|
+
host: domain,
|
|
1275
|
+
path: routePath,
|
|
1276
|
+
route: {
|
|
1277
|
+
sourceHost: fromUrl.host,
|
|
1278
|
+
cleanUrls,
|
|
1279
|
+
changeOrigin: option.changeOrigin || false,
|
|
1280
|
+
pathRewrites: option.pathRewrites,
|
|
1281
|
+
basePath,
|
|
1282
|
+
},
|
|
1266
1283
|
})
|
|
1267
|
-
debugLog('proxies', `Route: ${domain} → ${fromUrl.host}`, verbose)
|
|
1284
|
+
debugLog('proxies', `Route: ${domain}${routePath ?? ''} → ${fromUrl.host}`, verbose)
|
|
1268
1285
|
}
|
|
1269
1286
|
|
|
1270
1287
|
// Ensure hosts file entries exist for non-localhost domains. A wildcard
|
|
1271
1288
|
// domain (`*.example.com`) has no single hosts entry — skip it. Skipped
|
|
1272
|
-
// entirely when hosts management is disabled (real-server mode).
|
|
1289
|
+
// entirely when hosts management is disabled (real-server mode). Add each
|
|
1290
|
+
// domain once even when several path-routes share it.
|
|
1291
|
+
if (seenDomains.has(domain)) {
|
|
1292
|
+
continue
|
|
1293
|
+
}
|
|
1294
|
+
seenDomains.add(domain)
|
|
1273
1295
|
if (hostsEnabled && !isWildcardPattern(domain) && !domain.includes('localhost') && !domain.includes('127.0.0.1')) {
|
|
1274
1296
|
try {
|
|
1275
1297
|
const hostsExist = await checkHosts([domain], verbose)
|
|
@@ -1300,7 +1322,11 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1300
1322
|
return
|
|
1301
1323
|
}
|
|
1302
1324
|
|
|
1303
|
-
const
|
|
1325
|
+
const routingTable = buildHostRoutes(routeEntries)
|
|
1326
|
+
const sharedFetchHandler = createProxyFetchHandler(
|
|
1327
|
+
(host, pathname) => matchHostRoute(routingTable, host, pathname),
|
|
1328
|
+
verbose,
|
|
1329
|
+
)
|
|
1304
1330
|
const sharedWsHandler = createProxyWebSocketHandler(verbose)
|
|
1305
1331
|
|
|
1306
1332
|
try {
|
package/src/types.ts
CHANGED
|
@@ -57,6 +57,13 @@ export interface BaseProxyConfig {
|
|
|
57
57
|
*/
|
|
58
58
|
from?: string // localhost:5173
|
|
59
59
|
to: string // stacks.localhost
|
|
60
|
+
/**
|
|
61
|
+
* Optional path prefix this route owns under the host `to` (e.g. `'/api'`).
|
|
62
|
+
* Lets multiple routes share one host, each serving a different path —
|
|
63
|
+
* `/api` proxied to an app, `/docs` from a static dir, `/` from another.
|
|
64
|
+
* The longest matching prefix wins; omit (or `'/'`) for the host default.
|
|
65
|
+
*/
|
|
66
|
+
path?: string
|
|
60
67
|
start?: StartOptions
|
|
61
68
|
pathRewrites?: PathRewrite[]
|
|
62
69
|
/**
|
|
@@ -113,6 +120,60 @@ export interface ProductionTlsConfig {
|
|
|
113
120
|
certsDir?: string
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
/**
|
|
124
|
+
* On-demand TLS: issue a real (Let's Encrypt, http-01) certificate for an
|
|
125
|
+
* unknown host the first time it's needed, gated by an `ask` callback and/or an
|
|
126
|
+
* `allowedSuffixes` allowlist to prevent abuse.
|
|
127
|
+
*
|
|
128
|
+
* ## Why this is "ask-gated issuance + listener recreate", not at-handshake
|
|
129
|
+
*
|
|
130
|
+
* Bun (verified on 1.3.14 + 1.4.0) has **no working SNICallback** and
|
|
131
|
+
* `server.reload({ tls })` does **not** update certs at runtime. So rpx cannot
|
|
132
|
+
* mint a cert during the TLS handshake the way Caddy's on-demand TLS does.
|
|
133
|
+
* Instead rpx:
|
|
134
|
+
* 1. Sees the first plaintext request for the host on its `:80` listener.
|
|
135
|
+
* 2. Asks `ask(host)` / checks `allowedSuffixes`; if approved, drives the
|
|
136
|
+
* ACME http-01 flow (serving the challenge from its own `:80`).
|
|
137
|
+
* 3. Writes the PEMs into `certsDir` and rebuilds the `:443` listener with the
|
|
138
|
+
* augmented SNI cert set (a sub-second `server.stop()` + re-`Bun.serve`).
|
|
139
|
+
* The subsequent HTTPS request then finds the freshly-issued cert.
|
|
140
|
+
*
|
|
141
|
+
* Issuance can also be triggered programmatically via the manager's
|
|
142
|
+
* `ensureCert(host)` (e.g. a tunnel server pre-warming a subdomain's cert on
|
|
143
|
+
* registration) so the cert exists before the first browser hit.
|
|
144
|
+
*/
|
|
145
|
+
export interface OnDemandTlsConfig {
|
|
146
|
+
/** Master switch. On-demand TLS is opt-in; default `false`. */
|
|
147
|
+
enabled?: boolean
|
|
148
|
+
/**
|
|
149
|
+
* Gate issuance for a given hostname. Return `true` to allow rpx to obtain a
|
|
150
|
+
* cert, `false` to refuse. Combined with {@link allowedSuffixes} (a host is
|
|
151
|
+
* approved if either the suffix allowlist matches OR `ask` returns true). If
|
|
152
|
+
* neither is provided, on-demand issuance refuses every host.
|
|
153
|
+
*/
|
|
154
|
+
ask?: (host: string) => boolean | Promise<boolean>
|
|
155
|
+
/**
|
|
156
|
+
* Allowlist of domain suffixes that may be auto-issued without consulting
|
|
157
|
+
* `ask`. A host matches a suffix when it equals it or ends with `.<suffix>`
|
|
158
|
+
* (so `example.com` allows `example.com` and `a.example.com`).
|
|
159
|
+
*/
|
|
160
|
+
allowedSuffixes?: string[]
|
|
161
|
+
/** Contact email for the ACME account (recommended by Let's Encrypt). */
|
|
162
|
+
email?: string
|
|
163
|
+
/**
|
|
164
|
+
* Use Let's Encrypt **staging** (untrusted but un-rate-limited) instead of
|
|
165
|
+
* production. Default `false` (real, trusted, rate-limited certs).
|
|
166
|
+
*/
|
|
167
|
+
staging?: boolean
|
|
168
|
+
/**
|
|
169
|
+
* Directory where issued PEMs are written (`<host>.crt` / `<host>.key`) and
|
|
170
|
+
* from which existing certs are loaded. Should match the SNI `certsDir` so
|
|
171
|
+
* issued certs survive restarts. Defaults to the daemon's productionCerts
|
|
172
|
+
* `certsDir` when wired through the daemon.
|
|
173
|
+
*/
|
|
174
|
+
certsDir?: string
|
|
175
|
+
}
|
|
176
|
+
|
|
116
177
|
export interface SharedProxyConfig {
|
|
117
178
|
https: boolean | TlsOption
|
|
118
179
|
cleanup: boolean | CleanupOptions
|
|
@@ -142,6 +203,11 @@ export interface SharedProxyConfig {
|
|
|
142
203
|
* instead of the dev self-signed shared cert.
|
|
143
204
|
*/
|
|
144
205
|
productionCerts?: ProductionTlsConfig
|
|
206
|
+
/**
|
|
207
|
+
* On-demand TLS: lazily issue a real cert for an unknown (but approved) host
|
|
208
|
+
* the first time it's needed. Opt-in — see {@link OnDemandTlsConfig}.
|
|
209
|
+
*/
|
|
210
|
+
onDemandTls?: OnDemandTlsConfig
|
|
145
211
|
}
|
|
146
212
|
|
|
147
213
|
export type SharedProxyOptions = Partial<SharedProxyConfig>
|