@stacksjs/rpx 0.11.19 → 0.11.21
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/acme-challenge.d.ts +13 -0
- package/dist/auth.d.ts +21 -0
- package/dist/bin/cli.js +252 -200
- package/dist/cert-inspect.d.ts +5 -3
- package/dist/{chunk-83dqq28c.js → chunk-1108y1dk.js} +1 -1
- package/dist/{chunk-0f32jmrb.js → chunk-c9brkawa.js} +1 -1
- package/dist/{chunk-w888yhnp.js → chunk-cqkz06bg.js} +1 -1
- package/dist/chunk-qs36t2rf.js +224 -0
- package/dist/daemon.d.ts +4 -1
- package/dist/https.d.ts +1 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +7 -7
- package/dist/proxy-handler.d.ts +18 -1
- package/dist/proxy-pool.d.ts +5 -0
- package/dist/redirect.d.ts +40 -0
- package/dist/registry.d.ts +2 -1
- package/dist/site-resolver.d.ts +106 -0
- package/dist/site-splash.d.ts +19 -0
- package/dist/site-supervisor.d.ts +56 -0
- package/dist/start.d.ts +31 -8
- package/dist/types.d.ts +64 -0
- package/package.json +6 -7
- package/dist/chunk-rs8gqpax.js +0 -174
- package/src/cert-inspect.ts +0 -69
- package/src/colors.ts +0 -13
- package/src/config.ts +0 -45
- package/src/daemon-runner.ts +0 -180
- package/src/daemon.ts +0 -1209
- package/src/dns-state.ts +0 -116
- package/src/dns.ts +0 -568
- package/src/host-match.ts +0 -52
- package/src/host-routes.ts +0 -147
- package/src/hosts.ts +0 -283
- package/src/https.ts +0 -905
- package/src/index.ts +0 -161
- package/src/logger.ts +0 -19
- package/src/macos-trust.ts +0 -175
- package/src/on-demand.ts +0 -264
- package/src/origin-guard.ts +0 -127
- package/src/port-manager.ts +0 -183
- package/src/process-manager.ts +0 -164
- package/src/proxy-handler.ts +0 -387
- package/src/proxy-pool.ts +0 -1003
- package/src/registry.ts +0 -366
- package/src/sni.ts +0 -93
- package/src/start.ts +0 -1421
- package/src/static-files.ts +0 -201
- package/src/types.ts +0 -267
- package/src/utils.ts +0 -243
package/src/index.ts
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { startProxies as startProxiesFunc } from './start'
|
|
2
|
-
|
|
3
|
-
export { colors } from './colors'
|
|
4
|
-
|
|
5
|
-
export { config, config as defaultConfig } from './config'
|
|
6
|
-
|
|
7
|
-
export {
|
|
8
|
-
addHosts,
|
|
9
|
-
checkHosts,
|
|
10
|
-
removeHosts,
|
|
11
|
-
} from './hosts'
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
SHARED_DEV_HOST_CERT_PATH,
|
|
15
|
-
buildRegistryTlsProxyOptions,
|
|
16
|
-
checkExistingCertificates,
|
|
17
|
-
cleanupCertificates,
|
|
18
|
-
clearSslConfigCache,
|
|
19
|
-
devSslToSniEntries,
|
|
20
|
-
forceTrustCertificate,
|
|
21
|
-
generateCertificate,
|
|
22
|
-
getRootCAPaths,
|
|
23
|
-
getSharedDaemonCertPaths,
|
|
24
|
-
httpsConfig,
|
|
25
|
-
isCertTrusted,
|
|
26
|
-
loadSSLConfig,
|
|
27
|
-
} from './https'
|
|
28
|
-
|
|
29
|
-
export {
|
|
30
|
-
MACOS_CA_TRUST_FLAGS,
|
|
31
|
-
MACOS_SYSTEM_KEYCHAIN,
|
|
32
|
-
RPX_ROOT_CA_COMMON_NAME,
|
|
33
|
-
getMacosLoginKeychainPath,
|
|
34
|
-
getMacosTrustKeychains,
|
|
35
|
-
isRootCaFingerprintInKeychains,
|
|
36
|
-
isRootCaTrustedForSsl,
|
|
37
|
-
listCertSha256HashesByCommonName,
|
|
38
|
-
pruneStaleRootCas,
|
|
39
|
-
trustRootCaForBrowsers,
|
|
40
|
-
} from './macos-trust'
|
|
41
|
-
|
|
42
|
-
export {
|
|
43
|
-
certIncludesSanHostnames,
|
|
44
|
-
normalizeSha256Fingerprint,
|
|
45
|
-
parseSha256HashesFromSecurityListing,
|
|
46
|
-
readCertCommonName,
|
|
47
|
-
readCertSha256Fingerprint,
|
|
48
|
-
verifyHttpsChain,
|
|
49
|
-
} from './cert-inspect'
|
|
50
|
-
|
|
51
|
-
export { DefaultPortManager, findAvailablePort, isPortInUse, portManager } from './port-manager'
|
|
52
|
-
|
|
53
|
-
export {
|
|
54
|
-
gcStaleEntries,
|
|
55
|
-
getRegistryDir,
|
|
56
|
-
isPidAlive,
|
|
57
|
-
isValidId,
|
|
58
|
-
readAll,
|
|
59
|
-
readEntry,
|
|
60
|
-
removeEntry,
|
|
61
|
-
watchRegistry,
|
|
62
|
-
writeEntry,
|
|
63
|
-
} from './registry'
|
|
64
|
-
|
|
65
|
-
export type { RegistryEntry, WatchHandle, WatchOptions } from './registry'
|
|
66
|
-
|
|
67
|
-
export {
|
|
68
|
-
DNS_PORT,
|
|
69
|
-
RPX_RESOLVER_MARKER,
|
|
70
|
-
contentLooksLikeRpxResolver,
|
|
71
|
-
isDnsServerRunning,
|
|
72
|
-
reconcileStaleDevelopmentDns,
|
|
73
|
-
removeLegacyTldResolvers,
|
|
74
|
-
removeResolver,
|
|
75
|
-
resolverFilePath,
|
|
76
|
-
setupDevelopmentDns,
|
|
77
|
-
setupResolver,
|
|
78
|
-
startDnsServer,
|
|
79
|
-
stopDnsServer,
|
|
80
|
-
syncDevelopmentDnsFromRegistry,
|
|
81
|
-
tearDownDevelopmentDns,
|
|
82
|
-
} from './dns'
|
|
83
|
-
|
|
84
|
-
export type { DevelopmentDnsOptions } from './dns'
|
|
85
|
-
|
|
86
|
-
export {
|
|
87
|
-
DNS_STATE_VERSION,
|
|
88
|
-
LEGACY_TLD_RESOLVER_LABELS,
|
|
89
|
-
devDomainsFromHosts,
|
|
90
|
-
normalizeDevDomain,
|
|
91
|
-
resolverBasenameForDomain,
|
|
92
|
-
resolverBasenamesForDomains,
|
|
93
|
-
} from './dns-state'
|
|
94
|
-
|
|
95
|
-
export {
|
|
96
|
-
acquireDaemonLock,
|
|
97
|
-
defaultDaemonSpawnCommand,
|
|
98
|
-
ensureDaemonRunning,
|
|
99
|
-
getDaemonPidPath,
|
|
100
|
-
getDaemonRpxDir,
|
|
101
|
-
isDaemonRunning,
|
|
102
|
-
readDaemonPid,
|
|
103
|
-
reconcileDevelopmentDnsOnIdle,
|
|
104
|
-
releaseDaemonLock,
|
|
105
|
-
runDaemon,
|
|
106
|
-
stopDaemon,
|
|
107
|
-
} from './daemon'
|
|
108
|
-
|
|
109
|
-
export type {
|
|
110
|
-
DaemonHandle,
|
|
111
|
-
DaemonOptions,
|
|
112
|
-
EnsureDaemonOptions,
|
|
113
|
-
EnsureDaemonResult,
|
|
114
|
-
StopDaemonOptions,
|
|
115
|
-
StopDaemonResult,
|
|
116
|
-
} from './daemon'
|
|
117
|
-
|
|
118
|
-
export { createProxyFetchHandler, createProxyWebSocketHandler, stripBasePath } from './proxy-handler'
|
|
119
|
-
export type { GetRoute, ProxyFetchHandler, ProxyRoute, ProxyServer } from './proxy-handler'
|
|
120
|
-
|
|
121
|
-
export { isWildcardPattern, matchesWildcard, matchHost } from './host-match'
|
|
122
|
-
|
|
123
|
-
export { createOriginGuard } from './origin-guard'
|
|
124
|
-
export type { OriginGuard, OriginGuardOptions } from './origin-guard'
|
|
125
|
-
|
|
126
|
-
export {
|
|
127
|
-
buildHostRoutes,
|
|
128
|
-
matchHostList,
|
|
129
|
-
matchHostRoute,
|
|
130
|
-
normalizePathPrefix,
|
|
131
|
-
pathPrefixMatches,
|
|
132
|
-
} from './host-routes'
|
|
133
|
-
export type { HostRoutes, PathRoute } from './host-routes'
|
|
134
|
-
|
|
135
|
-
export {
|
|
136
|
-
contentTypeFor,
|
|
137
|
-
resolveStaticFile,
|
|
138
|
-
resolveStaticRoute,
|
|
139
|
-
safeRelativePath,
|
|
140
|
-
serveStaticFile,
|
|
141
|
-
} from './static-files'
|
|
142
|
-
export type { ResolvedStaticRoute, StaticResolution } from './static-files'
|
|
143
|
-
|
|
144
|
-
export { buildSniTlsConfig, serverNameFromCertFilename } from './sni'
|
|
145
|
-
export type { SniTlsEntry } from './sni'
|
|
146
|
-
|
|
147
|
-
export { isLikelyHostname, matchesAllowedSuffix, OnDemandCertManager } from './on-demand'
|
|
148
|
-
export type { CertIssuer, OnDemandCertManagerOptions } from './on-demand'
|
|
149
|
-
|
|
150
|
-
export { deriveIdFromTarget, runViaDaemon } from './daemon-runner'
|
|
151
|
-
export type { DaemonRunnerOptions, DaemonRunnerProxy } from './daemon-runner'
|
|
152
|
-
|
|
153
|
-
export { cleanup } from './start'
|
|
154
|
-
|
|
155
|
-
export { startProxies, startProxy, startServer } from './start'
|
|
156
|
-
|
|
157
|
-
export * from './types'
|
|
158
|
-
|
|
159
|
-
export * from './utils'
|
|
160
|
-
|
|
161
|
-
export default startProxiesFunc
|
package/src/logger.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export const log: {
|
|
2
|
-
info: (...args: unknown[]) => void
|
|
3
|
-
success: (...args: unknown[]) => void
|
|
4
|
-
warn: (...args: unknown[]) => void
|
|
5
|
-
error: (...args: unknown[]) => void
|
|
6
|
-
debug: (...args: unknown[]) => void
|
|
7
|
-
log: (...args: unknown[]) => void
|
|
8
|
-
start: (...args: unknown[]) => void
|
|
9
|
-
box: (...args: unknown[]) => void
|
|
10
|
-
} = {
|
|
11
|
-
info: (...args: unknown[]) => console.log('[info]', ...args),
|
|
12
|
-
success: (...args: unknown[]) => console.log('[success]', ...args),
|
|
13
|
-
warn: (...args: unknown[]) => console.warn('[warn]', ...args),
|
|
14
|
-
error: (...args: unknown[]) => console.error('[error]', ...args),
|
|
15
|
-
debug: (...args: unknown[]) => console.debug('[debug]', ...args),
|
|
16
|
-
log: (...args: unknown[]) => console.log(...args),
|
|
17
|
-
start: (...args: unknown[]) => console.log('[start]', ...args),
|
|
18
|
-
box: (...args: unknown[]) => console.log('[box]', ...args),
|
|
19
|
-
}
|
package/src/macos-trust.ts
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { execSync } from 'node:child_process'
|
|
2
|
-
import { homedir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
import { readCertSha256Fingerprint } from './cert-inspect'
|
|
5
|
-
import { debugLog, execSudoSync } from './utils'
|
|
6
|
-
|
|
7
|
-
/** Chrome/Edge need SSL + basic trust policies — plain trustRoot often leaves "trust settings: 0". */
|
|
8
|
-
export const MACOS_CA_TRUST_FLAGS = '-d -r trustRoot -p ssl -p basic'
|
|
9
|
-
|
|
10
|
-
export const MACOS_SYSTEM_KEYCHAIN = '/Library/Keychains/System.keychain'
|
|
11
|
-
|
|
12
|
-
/** Default CN label for the shared rpx Root CA in macOS keychain listings. */
|
|
13
|
-
export const RPX_ROOT_CA_COMMON_NAME = 'rpx.localhost'
|
|
14
|
-
|
|
15
|
-
export function getMacosLoginKeychainPath(): string {
|
|
16
|
-
return join(homedir(), 'Library/Keychains/login.keychain-db')
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function getMacosTrustKeychains(): string[] {
|
|
20
|
-
return [MACOS_SYSTEM_KEYCHAIN, getMacosLoginKeychainPath()]
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface ListCertsByCommonNameOptions {
|
|
24
|
-
keychain: string
|
|
25
|
-
commonName?: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function listCertSha256HashesByCommonName(
|
|
29
|
-
keychain: string,
|
|
30
|
-
commonName: string = RPX_ROOT_CA_COMMON_NAME,
|
|
31
|
-
): string[] {
|
|
32
|
-
const listing = execSync(
|
|
33
|
-
`security find-certificate -a -c "${commonName}" -Z "${keychain}" 2>/dev/null || true`,
|
|
34
|
-
{ encoding: 'utf8' },
|
|
35
|
-
)
|
|
36
|
-
const hashes: string[] = []
|
|
37
|
-
for (const line of listing.split('\n')) {
|
|
38
|
-
const match = line.match(/SHA-256 hash:\s*([A-F0-9]+)/i)
|
|
39
|
-
if (match)
|
|
40
|
-
hashes.push(match[1]!.toUpperCase())
|
|
41
|
-
}
|
|
42
|
-
return hashes
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface PruneStaleRootCasOptions {
|
|
46
|
-
caPath: string
|
|
47
|
-
commonName?: string
|
|
48
|
-
keychains?: string[]
|
|
49
|
-
verbose?: boolean
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Remove older rpx Root CA copies from keychains, keeping only the fingerprint
|
|
54
|
-
* that matches the on-disk `caPath` file.
|
|
55
|
-
*/
|
|
56
|
-
export function pruneStaleRootCas(options: PruneStaleRootCasOptions): void {
|
|
57
|
-
if (process.platform !== 'darwin')
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
const keep = readCertSha256Fingerprint(options.caPath)
|
|
61
|
-
if (!keep)
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
const commonName = options.commonName ?? RPX_ROOT_CA_COMMON_NAME
|
|
65
|
-
const keychains = options.keychains ?? getMacosTrustKeychains()
|
|
66
|
-
|
|
67
|
-
for (const keychain of keychains) {
|
|
68
|
-
for (const hash of listCertSha256HashesByCommonName(keychain, commonName)) {
|
|
69
|
-
if (hash === keep)
|
|
70
|
-
continue
|
|
71
|
-
try {
|
|
72
|
-
if (keychain.startsWith('/Library'))
|
|
73
|
-
execSudoSync(`security delete-certificate -Z ${hash} "${keychain}"`)
|
|
74
|
-
else
|
|
75
|
-
execSync(`security delete-certificate -Z ${hash} "${keychain}"`, { stdio: 'ignore' })
|
|
76
|
-
debugLog('ssl', `Removed stale Root CA ${hash} from ${keychain}`, options.verbose)
|
|
77
|
-
}
|
|
78
|
-
catch { /* already removed */ }
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* True when the Root CA is trusted for SSL to `serverName` (macOS verify-cert).
|
|
85
|
-
* On other platforms, falls back to fingerprint presence in trust stores.
|
|
86
|
-
*/
|
|
87
|
-
export function isRootCaTrustedForSsl(
|
|
88
|
-
caPath: string,
|
|
89
|
-
serverName: string,
|
|
90
|
-
options?: { verbose?: boolean },
|
|
91
|
-
): boolean {
|
|
92
|
-
if (process.platform !== 'darwin')
|
|
93
|
-
return isRootCaFingerprintInKeychains(caPath, options)
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const out = execSync(
|
|
97
|
-
`security verify-cert -c "${caPath}" -s "${serverName}" -l -L -R ssl 2>&1`,
|
|
98
|
-
{ encoding: 'utf8' },
|
|
99
|
-
)
|
|
100
|
-
const ok = out.includes('successful')
|
|
101
|
-
debugLog('ssl', `verify-cert ${serverName}: ${ok ? 'trusted' : 'not trusted'}`, options?.verbose)
|
|
102
|
-
return ok
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
return false
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function isRootCaFingerprintInKeychains(
|
|
110
|
-
caPath: string,
|
|
111
|
-
options?: { verbose?: boolean },
|
|
112
|
-
): boolean {
|
|
113
|
-
const fp = readCertSha256Fingerprint(caPath)
|
|
114
|
-
if (!fp)
|
|
115
|
-
return false
|
|
116
|
-
|
|
117
|
-
for (const keychain of getMacosTrustKeychains()) {
|
|
118
|
-
try {
|
|
119
|
-
const listing = execSync(`security find-certificate -a -Z "${keychain}" 2>/dev/null || true`, { encoding: 'utf8' })
|
|
120
|
-
for (const line of listing.split('\n')) {
|
|
121
|
-
if (line.toUpperCase().includes('SHA-256')) {
|
|
122
|
-
const lineFp = line.split('=').pop()!.replace(/SHA-256\s+hash:\s*/gi, '').replace(/:/g, '').trim().toUpperCase()
|
|
123
|
-
if (lineFp === fp) {
|
|
124
|
-
debugLog('ssl', `Root CA fingerprint found in ${keychain}`, options?.verbose)
|
|
125
|
-
return true
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
catch { /* try next keychain */ }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return false
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export interface TrustRootCaForBrowsersOptions {
|
|
137
|
-
serverName: string
|
|
138
|
-
commonName?: string
|
|
139
|
-
verbose?: boolean
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Install the Root CA into login + system keychains with SSL/basic policies,
|
|
144
|
-
* pruning stale copies first. Returns true when SSL verification succeeds or
|
|
145
|
-
* the cert fingerprint is present in a keychain.
|
|
146
|
-
*/
|
|
147
|
-
export function trustRootCaForBrowsers(
|
|
148
|
-
caPath: string,
|
|
149
|
-
options: TrustRootCaForBrowsersOptions,
|
|
150
|
-
): boolean {
|
|
151
|
-
if (process.platform !== 'darwin')
|
|
152
|
-
return false
|
|
153
|
-
|
|
154
|
-
const serverName = options.serverName
|
|
155
|
-
pruneStaleRootCas({ caPath, commonName: options.commonName, verbose: options.verbose })
|
|
156
|
-
|
|
157
|
-
const loginKeychain = getMacosLoginKeychainPath()
|
|
158
|
-
try {
|
|
159
|
-
execSync(
|
|
160
|
-
`security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k "${loginKeychain}" "${caPath}"`,
|
|
161
|
-
{ stdio: 'ignore' },
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
catch { /* may already exist — re-apply trust below */ }
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
execSudoSync(`security add-trusted-cert ${MACOS_CA_TRUST_FLAGS} -k ${MACOS_SYSTEM_KEYCHAIN} "${caPath}"`)
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
return false
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return isRootCaTrustedForSsl(caPath, serverName, { verbose: options.verbose })
|
|
174
|
-
|| isRootCaFingerprintInKeychains(caPath, { verbose: options.verbose })
|
|
175
|
-
}
|
package/src/on-demand.ts
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
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
|
-
}
|