@stacksjs/rpx 0.11.7 → 0.11.10
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 +170 -164
- package/dist/cert-inspect.d.ts +15 -0
- package/dist/chunk-3yvvmvc0.js +1 -0
- package/dist/{chunk-jpf41gb9.js → chunk-97manwts.js} +1 -1
- package/dist/chunk-krj531r2.js +1 -0
- package/dist/chunk-ppbddztz.js +156 -0
- package/dist/daemon-runner.d.ts +3 -0
- package/dist/daemon.d.ts +4 -0
- package/dist/dns-state.d.ts +27 -0
- package/dist/dns.d.ts +43 -0
- package/dist/https.d.ts +32 -2
- package/dist/index.d.ts +49 -0
- package/dist/index.js +6 -153
- package/dist/macos-trust.d.ts +40 -0
- package/package.json +1 -1
- package/src/cert-inspect.ts +69 -0
- package/src/daemon-runner.ts +15 -2
- package/src/daemon.ts +70 -9
- package/src/dns-state.ts +116 -0
- package/src/dns.ts +252 -142
- package/src/https.ts +94 -53
- package/src/index.ts +54 -0
- package/src/macos-trust.ts +175 -0
- package/src/start.ts +7 -9
- package/dist/chunk-6z1nzq0x.js +0 -1
- package/dist/chunk-qcdcnadb.js +0 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare function getMacosLoginKeychainPath(): string;
|
|
2
|
+
export declare function getMacosTrustKeychains(): string[];
|
|
3
|
+
export declare function listCertSha256HashesByCommonName(keychain: string, commonName?: string): string[];
|
|
4
|
+
/**
|
|
5
|
+
* Remove older rpx Root CA copies from keychains, keeping only the fingerprint
|
|
6
|
+
* that matches the on-disk `caPath` file.
|
|
7
|
+
*/
|
|
8
|
+
export declare function pruneStaleRootCas(options: PruneStaleRootCasOptions): void;
|
|
9
|
+
/**
|
|
10
|
+
* True when the Root CA is trusted for SSL to `serverName` (macOS verify-cert).
|
|
11
|
+
* On other platforms, falls back to fingerprint presence in trust stores.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isRootCaTrustedForSsl(caPath: string, serverName: string, options?: { verbose?: boolean }): boolean;
|
|
14
|
+
export declare function isRootCaFingerprintInKeychains(caPath: string, options?: { verbose?: boolean }): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Install the Root CA into login + system keychains with SSL/basic policies,
|
|
17
|
+
* pruning stale copies first. Returns true when SSL verification succeeds or
|
|
18
|
+
* the cert fingerprint is present in a keychain.
|
|
19
|
+
*/
|
|
20
|
+
export declare function trustRootCaForBrowsers(caPath: string, options: TrustRootCaForBrowsersOptions): boolean;
|
|
21
|
+
/** Chrome/Edge need SSL + basic trust policies — plain trustRoot often leaves "trust settings: 0". */
|
|
22
|
+
export declare const MACOS_CA_TRUST_FLAGS: '-d -r trustRoot -p ssl -p basic';
|
|
23
|
+
export declare const MACOS_SYSTEM_KEYCHAIN: '/Library/Keychains/System.keychain';
|
|
24
|
+
/** Default CN label for the shared rpx Root CA in macOS keychain listings. */
|
|
25
|
+
export declare const RPX_ROOT_CA_COMMON_NAME: 'rpx.localhost';
|
|
26
|
+
export declare interface ListCertsByCommonNameOptions {
|
|
27
|
+
keychain: string
|
|
28
|
+
commonName?: string
|
|
29
|
+
}
|
|
30
|
+
export declare interface PruneStaleRootCasOptions {
|
|
31
|
+
caPath: string
|
|
32
|
+
commonName?: string
|
|
33
|
+
keychains?: string[]
|
|
34
|
+
verbose?: boolean
|
|
35
|
+
}
|
|
36
|
+
export declare interface TrustRootCaForBrowsersOptions {
|
|
37
|
+
serverName: string
|
|
38
|
+
commonName?: string
|
|
39
|
+
verbose?: boolean
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize openssl / security fingerprint output to uppercase hex without separators.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeSha256Fingerprint(raw: string): string {
|
|
7
|
+
const value = raw.includes('=') ? raw.split('=').pop()! : raw
|
|
8
|
+
return value.replace(/SHA-256\s+hash:\s*/gi, '').replace(/:/g, '').trim().toUpperCase()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readCertSha256Fingerprint(certPath: string): string | null {
|
|
12
|
+
try {
|
|
13
|
+
const out = execSync(`openssl x509 -noout -fingerprint -sha256 -in "${certPath}"`, { encoding: 'utf8' })
|
|
14
|
+
return normalizeSha256Fingerprint(out)
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readCertCommonName(certPath: string): string | null {
|
|
22
|
+
try {
|
|
23
|
+
const subject = execSync(`openssl x509 -in "${certPath}" -noout -subject -nameopt RFC2253`, { encoding: 'utf8' })
|
|
24
|
+
const match = subject.match(/CN=([^,/]+)/)
|
|
25
|
+
return match?.[1]?.trim() ?? null
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function certIncludesSanHostnames(certPath: string, hostnames: string[]): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const text = execSync(`openssl x509 -in "${certPath}" -noout -text`, { encoding: 'utf8' })
|
|
35
|
+
return hostnames.every(host => text.includes(`DNS:${host}`))
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True when :443 (or `port`) presents a chain trusted by `caPath` for `domain`.
|
|
44
|
+
*/
|
|
45
|
+
export function verifyHttpsChain(domain: string, caPath: string, port = 443): boolean {
|
|
46
|
+
try {
|
|
47
|
+
const out = execSync(
|
|
48
|
+
`echo | openssl s_client -connect ${domain}:${port} -servername ${domain} -CAfile "${caPath}" 2>/dev/null | grep "Verify return code"`,
|
|
49
|
+
{ encoding: 'utf8', timeout: 4000 },
|
|
50
|
+
)
|
|
51
|
+
return out.includes(': 0 (ok)')
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse `security find-certificate -Z` listing lines into SHA-256 hashes.
|
|
60
|
+
*/
|
|
61
|
+
export function parseSha256HashesFromSecurityListing(listing: string): string[] {
|
|
62
|
+
const hashes: string[] = []
|
|
63
|
+
for (const line of listing.split('\n')) {
|
|
64
|
+
const match = line.match(/SHA-256 hash:\s*([A-F0-9]+)/i)
|
|
65
|
+
if (match)
|
|
66
|
+
hashes.push(match[1]!.toUpperCase())
|
|
67
|
+
}
|
|
68
|
+
return hashes
|
|
69
|
+
}
|
package/src/daemon-runner.ts
CHANGED
|
@@ -42,6 +42,16 @@ export interface DaemonRunnerOptions {
|
|
|
42
42
|
detached?: boolean
|
|
43
43
|
/** Override the daemon spawn command (tests). */
|
|
44
44
|
spawnCommand?: string[]
|
|
45
|
+
/** Passed through to `ensureDaemonRunning` (default 5000ms). */
|
|
46
|
+
startupTimeoutMs?: number
|
|
47
|
+
/** Extra env for the daemon child (e.g. `SUDO_PASSWORD`). */
|
|
48
|
+
spawnEnv?: Record<string, string>
|
|
49
|
+
/**
|
|
50
|
+
* When true, registry entries omit `pid` so they persist until
|
|
51
|
+
* `rpx unregister` — avoids PID-GC dropping routes when the registering
|
|
52
|
+
* process is short-lived (e.g. a CLI wrapper).
|
|
53
|
+
*/
|
|
54
|
+
persistent?: boolean
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/**
|
|
@@ -78,14 +88,15 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
|
|
|
78
88
|
return { ...p, id }
|
|
79
89
|
})
|
|
80
90
|
|
|
91
|
+
const createdAt = new Date().toISOString()
|
|
81
92
|
for (const p of resolved) {
|
|
82
93
|
await writeEntry({
|
|
83
94
|
id: p.id,
|
|
84
95
|
from: p.from,
|
|
85
96
|
to: p.to,
|
|
86
|
-
pid: process.pid,
|
|
97
|
+
pid: opts.persistent ? undefined : process.pid,
|
|
87
98
|
cwd: process.cwd(),
|
|
88
|
-
createdAt
|
|
99
|
+
createdAt,
|
|
89
100
|
cleanUrls: p.cleanUrls,
|
|
90
101
|
changeOrigin: p.changeOrigin,
|
|
91
102
|
pathRewrites: p.pathRewrites,
|
|
@@ -96,6 +107,8 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
|
|
|
96
107
|
rpxDir: opts.rpxDir,
|
|
97
108
|
verbose,
|
|
98
109
|
spawnCommand: opts.spawnCommand,
|
|
110
|
+
startupTimeoutMs: opts.startupTimeoutMs,
|
|
111
|
+
spawnEnv: opts.spawnEnv,
|
|
99
112
|
})
|
|
100
113
|
|
|
101
114
|
for (const p of resolved)
|
package/src/daemon.ts
CHANGED
|
@@ -28,6 +28,11 @@ import { checkExistingCertificates, generateCertificate } from './https'
|
|
|
28
28
|
import { createProxyFetchHandler } from './proxy-handler'
|
|
29
29
|
import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
|
|
30
30
|
import type { RegistryEntry } from './registry'
|
|
31
|
+
import {
|
|
32
|
+
reconcileStaleDevelopmentDns,
|
|
33
|
+
syncDevelopmentDnsFromRegistry,
|
|
34
|
+
tearDownDevelopmentDns,
|
|
35
|
+
} from './dns'
|
|
31
36
|
import { debugLog } from './utils'
|
|
32
37
|
|
|
33
38
|
export interface DaemonOptions {
|
|
@@ -156,16 +161,38 @@ function entryToRoute(entry: RegistryEntry): ProxyRoute {
|
|
|
156
161
|
* Bootstrap the daemon's TLS material. Reuses the persisted Root CA and any
|
|
157
162
|
* existing trusted host cert; mints fresh ones if none exist.
|
|
158
163
|
*
|
|
159
|
-
* The host cert
|
|
160
|
-
* `
|
|
161
|
-
*
|
|
164
|
+
* The host cert SAN list includes every hostname in the registry (e.g.
|
|
165
|
+
* `postline.localhost`, `api.postline.localhost`). Chrome does not treat
|
|
166
|
+
* `*.localhost` as matching `<app>.localhost`, so those names must be explicit.
|
|
162
167
|
*/
|
|
163
|
-
|
|
168
|
+
function pickPrimaryRegistryHost(hosts: string[]): string {
|
|
169
|
+
const appHost = hosts.find(h => !/^api\./.test(h) && !/^docs\./.test(h) && !/^dashboard\./.test(h))
|
|
170
|
+
return appHost ?? hosts[0] ?? 'rpx.localhost'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<SSLConfig> {
|
|
174
|
+
const entries = await readAll(registryDir, opts.verbose)
|
|
175
|
+
const registryHosts = [...new Set(entries.map(e => e.to))]
|
|
176
|
+
const primary = pickPrimaryRegistryHost(registryHosts)
|
|
177
|
+
const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
|
|
178
|
+
|
|
179
|
+
const sslDir = path.join(homedir(), '.stacks', 'ssl')
|
|
180
|
+
const sharedCert = path.join(sslDir, 'rpx.localhost.crt')
|
|
181
|
+
|
|
164
182
|
const proxyOpts: ProxyOptions = {
|
|
165
|
-
https: opts.https
|
|
166
|
-
|
|
183
|
+
https: typeof opts.https === 'object'
|
|
184
|
+
? { ...opts.https, certPath: sharedCert, keyPath: path.join(sslDir, 'rpx.localhost.key'), commonName: primary }
|
|
185
|
+
: {
|
|
186
|
+
certPath: sharedCert,
|
|
187
|
+
keyPath: path.join(sslDir, 'rpx.localhost.key'),
|
|
188
|
+
caCertPath: path.join(sslDir, 'rpx.localhost.ca.crt'),
|
|
189
|
+
commonName: primary,
|
|
190
|
+
},
|
|
167
191
|
verbose: opts.verbose,
|
|
168
192
|
regenerateUntrustedCerts: true,
|
|
193
|
+
...(hostnames.length > 1
|
|
194
|
+
? { proxies: hostnames.map(to => ({ from: 'localhost:1', to })) }
|
|
195
|
+
: { to: primary, from: 'localhost:1' }),
|
|
169
196
|
}
|
|
170
197
|
|
|
171
198
|
let sslConfig = await checkExistingCertificates(proxyOpts)
|
|
@@ -214,9 +241,17 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
214
241
|
await gcStaleEntries(registryDir, verbose).catch((err) => {
|
|
215
242
|
debugLog('daemon', `initial gc failed: ${err}`, verbose)
|
|
216
243
|
})
|
|
217
|
-
|
|
244
|
+
const initialEntries = await readAll(registryDir, verbose)
|
|
245
|
+
rebuild(initialEntries)
|
|
218
246
|
|
|
219
|
-
|
|
247
|
+
await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
|
|
248
|
+
debugLog('daemon', `DNS reconcile on start failed: ${err}`, verbose)
|
|
249
|
+
})
|
|
250
|
+
await syncDevelopmentDnsFromRegistry(initialEntries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
|
|
251
|
+
debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const sslConfig = await bootstrapTls(opts, registryDir)
|
|
220
255
|
|
|
221
256
|
const httpsServer = Bun.serve({
|
|
222
257
|
port: httpsPort,
|
|
@@ -258,7 +293,12 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
258
293
|
}
|
|
259
294
|
|
|
260
295
|
const watcher = watchRegistry(
|
|
261
|
-
(entries) => {
|
|
296
|
+
(entries) => {
|
|
297
|
+
rebuild(entries)
|
|
298
|
+
syncDevelopmentDnsFromRegistry(entries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
|
|
299
|
+
debugLog('daemon', `DNS sync on registry change failed: ${err}`, verbose)
|
|
300
|
+
})
|
|
301
|
+
},
|
|
262
302
|
{ dir: registryDir, verbose },
|
|
263
303
|
)
|
|
264
304
|
|
|
@@ -289,6 +329,9 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
289
329
|
// `stop(false)` lets in-flight requests drain before closing the listener.
|
|
290
330
|
httpsServer.stop(false)
|
|
291
331
|
httpServer?.stop(false)
|
|
332
|
+
await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
|
|
333
|
+
debugLog('daemon', `DNS teardown failed: ${err}`, verbose)
|
|
334
|
+
})
|
|
292
335
|
await releaseDaemonLock(rpxDir)
|
|
293
336
|
if (verbose)
|
|
294
337
|
log.info('rpx daemon stopped')
|
|
@@ -369,6 +412,10 @@ export async function ensureDaemonRunning(opts: EnsureDaemonOptions = {}): Promi
|
|
|
369
412
|
const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
|
|
370
413
|
const verbose = opts.verbose ?? false
|
|
371
414
|
|
|
415
|
+
await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
|
|
416
|
+
debugLog('daemon', `DNS reconcile before ensureDaemonRunning: ${err}`, verbose)
|
|
417
|
+
})
|
|
418
|
+
|
|
372
419
|
const existingPid = await readDaemonPid(rpxDir)
|
|
373
420
|
if (existingPid !== null && isPidAlive(existingPid)) {
|
|
374
421
|
debugLog('daemon', `ensureDaemonRunning: already running pid=${existingPid}`, verbose)
|
|
@@ -455,6 +502,7 @@ export async function stopDaemon(opts: StopDaemonOptions = {}): Promise<StopDaem
|
|
|
455
502
|
if (pid === null || !isPidAlive(pid)) {
|
|
456
503
|
if (pid !== null)
|
|
457
504
|
await releaseDaemonLock(rpxDir)
|
|
505
|
+
await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch(() => {})
|
|
458
506
|
return { stopped: false, pid, forced: false }
|
|
459
507
|
}
|
|
460
508
|
|
|
@@ -492,5 +540,18 @@ export async function stopDaemon(opts: StopDaemonOptions = {}): Promise<StopDaem
|
|
|
492
540
|
}
|
|
493
541
|
// SIGKILL bypasses the cleanup handler, so remove the pid file ourselves.
|
|
494
542
|
await releaseDaemonLock(rpxDir)
|
|
543
|
+
await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
|
|
544
|
+
debugLog('daemon', `DNS teardown after SIGKILL: ${err}`, verbose)
|
|
545
|
+
})
|
|
495
546
|
return { stopped: true, pid, forced: true }
|
|
496
547
|
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* When the daemon is not running, ensure no stale macOS resolver overrides remain.
|
|
551
|
+
*/
|
|
552
|
+
export async function reconcileDevelopmentDnsOnIdle(opts: { rpxDir?: string, verbose?: boolean } = {}): Promise<void> {
|
|
553
|
+
const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
|
|
554
|
+
if (await isDaemonRunning(rpxDir))
|
|
555
|
+
return
|
|
556
|
+
await reconcileStaleDevelopmentDns({ rpxDir, verbose: opts.verbose })
|
|
557
|
+
}
|
package/src/dns-state.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import * as fsp from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const DNS_STATE_VERSION = 1 as const
|
|
6
|
+
export const RPX_DNS_STATE_FILE = 'dns-state.json'
|
|
7
|
+
|
|
8
|
+
/** Single-label /etc/resolver files created by older rpx versions (whole-TLD hijack). */
|
|
9
|
+
export const LEGACY_TLD_RESOLVER_LABELS = [
|
|
10
|
+
'com',
|
|
11
|
+
'test',
|
|
12
|
+
'dev',
|
|
13
|
+
'app',
|
|
14
|
+
'page',
|
|
15
|
+
'local',
|
|
16
|
+
'localhost',
|
|
17
|
+
'example',
|
|
18
|
+
'invalid',
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
export interface DnsState {
|
|
22
|
+
version: typeof DNS_STATE_VERSION
|
|
23
|
+
/** Basenames under /etc/resolver/ (e.g. `postline.test`, not `test`). */
|
|
24
|
+
resolvers: string[]
|
|
25
|
+
domains: string[]
|
|
26
|
+
ownerPid: number | null
|
|
27
|
+
updatedAt: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defaultRpxDir(): string {
|
|
31
|
+
return path.join(homedir(), '.stacks', 'rpx')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getDnsStatePath(rpxDir: string = defaultRpxDir()): string {
|
|
35
|
+
return path.join(rpxDir, RPX_DNS_STATE_FILE)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function loadDnsState(rpxDir: string = defaultRpxDir()): Promise<DnsState | null> {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await fsp.readFile(getDnsStatePath(rpxDir), 'utf8')
|
|
41
|
+
const parsed = JSON.parse(raw) as Partial<DnsState>
|
|
42
|
+
if (parsed.version !== DNS_STATE_VERSION || !Array.isArray(parsed.resolvers))
|
|
43
|
+
return null
|
|
44
|
+
return {
|
|
45
|
+
version: DNS_STATE_VERSION,
|
|
46
|
+
resolvers: parsed.resolvers.filter((r): r is string => typeof r === 'string'),
|
|
47
|
+
domains: Array.isArray(parsed.domains)
|
|
48
|
+
? parsed.domains.filter((d): d is string => typeof d === 'string')
|
|
49
|
+
: [],
|
|
50
|
+
ownerPid: typeof parsed.ownerPid === 'number' ? parsed.ownerPid : null,
|
|
51
|
+
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
|
|
56
|
+
return null
|
|
57
|
+
throw err
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function saveDnsState(rpxDir: string, state: DnsState): Promise<void> {
|
|
62
|
+
await fsp.mkdir(rpxDir, { recursive: true })
|
|
63
|
+
await fsp.writeFile(getDnsStatePath(rpxDir), `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function clearDnsState(rpxDir: string): Promise<void> {
|
|
67
|
+
await fsp.rm(getDnsStatePath(rpxDir), { force: true })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a dev hostname. Returns null for localhost / IPs — those use /etc/hosts only.
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeDevDomain(raw: string): string | null {
|
|
74
|
+
const domain = raw.trim().toLowerCase().replace(/\.$/, '')
|
|
75
|
+
if (!domain || domain.includes('127.0.0.1'))
|
|
76
|
+
return null
|
|
77
|
+
if (domain === 'localhost' || domain.endsWith('.localhost'))
|
|
78
|
+
return null
|
|
79
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(domain))
|
|
80
|
+
return null
|
|
81
|
+
return domain
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* macOS resolver basename for a dev domain. Uses the registrable base (last two labels)
|
|
86
|
+
* so `api.postline.test` and `postline.test` share one `/etc/resolver/postline.test` file.
|
|
87
|
+
*/
|
|
88
|
+
export function resolverBasenameForDomain(raw: string): string | null {
|
|
89
|
+
const domain = normalizeDevDomain(raw)
|
|
90
|
+
if (!domain)
|
|
91
|
+
return null
|
|
92
|
+
const parts = domain.split('.')
|
|
93
|
+
if (parts.length < 2)
|
|
94
|
+
return null
|
|
95
|
+
return parts.slice(-2).join('.')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolverBasenamesForDomains(domains: string[]): string[] {
|
|
99
|
+
const basenames = new Set<string>()
|
|
100
|
+
for (const raw of domains) {
|
|
101
|
+
const basename = resolverBasenameForDomain(raw)
|
|
102
|
+
if (basename)
|
|
103
|
+
basenames.add(basename)
|
|
104
|
+
}
|
|
105
|
+
return Array.from(basenames).sort()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function devDomainsFromHosts(hosts: string[]): string[] {
|
|
109
|
+
const out = new Set<string>()
|
|
110
|
+
for (const raw of hosts) {
|
|
111
|
+
const domain = normalizeDevDomain(raw)
|
|
112
|
+
if (domain)
|
|
113
|
+
out.add(domain)
|
|
114
|
+
}
|
|
115
|
+
return Array.from(out).sort()
|
|
116
|
+
}
|