unplugin-https-reverse-proxy 4.0.4 → 4.1.0-beta.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/README.md +30 -6
- package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
- package/dist/chunk-RKHJN6KY.js +53 -0
- package/dist/{chunk-5R33PUJP.js → chunk-U7KAIPZC.js} +981 -796
- package/dist/chunk-WOMR3ZLM.js +17 -0
- package/dist/esbuild.cjs +1077 -864
- package/dist/esbuild.js +4 -2
- package/dist/index.cjs +1077 -864
- package/dist/index.js +4 -2
- package/dist/nuxt.cjs +1049 -850
- package/dist/nuxt.js +6 -9
- package/dist/privilege-state.cjs +44 -0
- package/dist/privilege-state.d.cts +7 -0
- package/dist/privilege-state.d.ts +7 -0
- package/dist/privilege-state.js +10 -0
- package/dist/privileged-helper.cjs +824 -0
- package/dist/privileged-helper.d.cts +2 -0
- package/dist/privileged-helper.d.ts +2 -0
- package/dist/privileged-helper.js +797 -0
- package/dist/rollup.cjs +1077 -864
- package/dist/rollup.js +4 -2
- package/dist/rspack.cjs +1077 -864
- package/dist/rspack.js +4 -2
- package/dist/utils.cjs +0 -28
- package/dist/utils.d.cts +1 -3
- package/dist/utils.d.ts +1 -3
- package/dist/utils.js +1 -5
- package/dist/vite.cjs +1077 -864
- package/dist/vite.js +4 -2
- package/dist/webpack.cjs +1077 -864
- package/dist/webpack.js +4 -2
- package/package.json +1 -4
- package/src/caddy/caddyfile.ts +3 -0
- package/src/caddy/config.ts +195 -53
- package/src/caddy/constants.ts +30 -1
- package/src/caddy/index.ts +239 -206
- package/src/caddy/privileged.ts +124 -0
- package/src/caddy/utils.ts +0 -43
- package/src/index.ts +49 -22
- package/src/nuxt.ts +1 -6
- package/src/privilege-state.ts +23 -0
- package/src/privileged/bootstrap.ts +289 -0
- package/src/privileged/client.ts +288 -0
- package/src/privileged/entry.ts +73 -0
- package/src/privileged/hosts-file.ts +142 -0
- package/src/privileged/launch.ts +332 -0
- package/src/privileged/path-guard.ts +74 -0
- package/src/privileged/ports.ts +45 -0
- package/src/privileged/protocol.ts +111 -0
- package/src/privileged/server.ts +578 -0
- package/src/privileged/session.ts +169 -0
- package/src/privileged/validation.ts +38 -0
- package/src/privileged/windows-elevation.ts +80 -0
- package/src/privileged-helper.ts +123 -0
- package/src/utils.ts +0 -28
- package/src/host/index.ts +0 -52
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
3
|
+
import type { HelperEvent, HelperRequest, HelperResponse } from './protocol'
|
|
4
|
+
import { execFile, spawn } from 'node:child_process'
|
|
5
|
+
import { lchownSync, lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { platform } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import process from 'node:process'
|
|
9
|
+
import { promisify } from 'node:util'
|
|
10
|
+
import { addHost, removeHost, removeMarkedHosts } from './hosts-file'
|
|
11
|
+
import { parseListeningPids, parseWindowsListeningPids } from './ports'
|
|
12
|
+
import { serializeMessage } from './protocol'
|
|
13
|
+
import { isAllowedFreePort, isSafeHost, isSafeIp } from './validation'
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile)
|
|
16
|
+
|
|
17
|
+
/** Runs a command whose non-zero exit is expected, keeping stdout. */
|
|
18
|
+
function execFileQuiet(file: string, args: string[]): Promise<{ stdout: string }> {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile(file, args, (error, stdout) => resolve({ stdout: error && !stdout ? '' : stdout }))
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const HOSTS_PATH = platform() === 'win32'
|
|
25
|
+
? 'C:\\Windows\\System32\\drivers\\etc\\hosts'
|
|
26
|
+
: '/etc/hosts'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Command that drops the resolver cache so a fresh hosts entry is visible
|
|
30
|
+
* immediately. Each platform caches differently, so the command is per-OS.
|
|
31
|
+
*/
|
|
32
|
+
const FLUSH_COMMANDS: Record<string, Array<{ file: string, args: string[] }>> = {
|
|
33
|
+
win32: [{ file: 'ipconfig', args: ['/flushdns'] }],
|
|
34
|
+
darwin: [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }],
|
|
35
|
+
// systemd-resolved is the common case; the legacy name covers older distros
|
|
36
|
+
// and hosts without resolved simply have no cache to drop.
|
|
37
|
+
linux: [
|
|
38
|
+
{ file: 'resolvectl', args: ['flush-caches'] },
|
|
39
|
+
{ file: 'systemd-resolve', args: ['--flush-caches'] },
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const FLUSH_FALLBACK = [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }] as const
|
|
44
|
+
|
|
45
|
+
/** Handle on the Caddy process owned by the helper. */
|
|
46
|
+
export interface CaddyChild {
|
|
47
|
+
pid?: number
|
|
48
|
+
kill: (signal?: NodeJS.Signals) => boolean
|
|
49
|
+
/** False once the process has exited; undefined when unknown. */
|
|
50
|
+
running?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Everything privileged the helper can do.
|
|
55
|
+
*
|
|
56
|
+
* Paths and arguments are fixed at helper start-up; the client only chooses
|
|
57
|
+
* when* something happens, never *what* runs. That keeps the privileged
|
|
58
|
+
* surface to a handful of primitives even if the socket is reached by an
|
|
59
|
+
* unexpected local process.
|
|
60
|
+
*/
|
|
61
|
+
export interface HelperDeps {
|
|
62
|
+
readHosts: () => string
|
|
63
|
+
writeHosts: (content: string) => void
|
|
64
|
+
flushDns: () => Promise<void>
|
|
65
|
+
/** Releases a port; never kills the pid in `keepPid`. */
|
|
66
|
+
freePort: (port: number, keepPid?: number) => Promise<number[]>
|
|
67
|
+
/** Caddy binary and the Caddyfile it must load, both fixed at start-up. */
|
|
68
|
+
caddyBin: string
|
|
69
|
+
caddyConfigPath: string
|
|
70
|
+
caddyCwd: string
|
|
71
|
+
caddyEnv: Record<string, string>
|
|
72
|
+
/** Installs the local root CA using a helper-owned command, not a client path. */
|
|
73
|
+
trustLocalCa: () => Promise<{ trusted: boolean, output: string }>
|
|
74
|
+
/** Caddy writes its PKI as root; this tree goes back to the user afterwards. */
|
|
75
|
+
caddyStoragePath?: string
|
|
76
|
+
storageOwner?: { uid: number, gid: number }
|
|
77
|
+
/** Starts Caddy with the configuration fixed at helper start-up. */
|
|
78
|
+
startCaddy: (emit: (event: HelperEvent) => void) => CaddyChild
|
|
79
|
+
stopCaddy: () => Promise<void>
|
|
80
|
+
log: (message: string) => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolver caching only affects how fast a new entry is picked up, so a failed
|
|
85
|
+
* flush is logged instead of rolling back a hosts edit that already succeeded.
|
|
86
|
+
*/
|
|
87
|
+
async function flushDnsBestEffort(deps: HelperDeps): Promise<void> {
|
|
88
|
+
try {
|
|
89
|
+
await deps.flushDns()
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
deps.log(`dns flush failed: ${(error as Error).message}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface HelperRuntime {
|
|
97
|
+
/** `client` identifies the caller so shared host mappings are reference-counted. */
|
|
98
|
+
handle: (request: HelperRequest, client?: object) => Promise<HelperResponse>
|
|
99
|
+
/** Registers a connected client so Caddy output reaches it. */
|
|
100
|
+
attach: (client: object) => void
|
|
101
|
+
/** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */
|
|
102
|
+
detach: (client: object) => Promise<void>
|
|
103
|
+
/** Runs the teardown once, regardless of how many clients were attached. */
|
|
104
|
+
dispose: () => Promise<void>
|
|
105
|
+
/** Removes hosts entries a previous crashed session left behind. */
|
|
106
|
+
sweepStaleEntries: () => Promise<boolean>
|
|
107
|
+
/** Number of attached dev servers; a signal must not tear down a live one. */
|
|
108
|
+
clientCount: () => number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Request router shared by the helper process and its tests. Kept free of
|
|
113
|
+
* sockets so tests can drive it against a temp hosts file and a fake Caddy.
|
|
114
|
+
*/
|
|
115
|
+
export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
116
|
+
const clients = new Set<object>()
|
|
117
|
+
let teardown: Promise<void> | undefined
|
|
118
|
+
// Bumped whenever a client attaches. A teardown that started before the bump
|
|
119
|
+
// belongs to a previous generation and must not touch the new session.
|
|
120
|
+
let generation = 0
|
|
121
|
+
let caddy: CaddyChild | undefined
|
|
122
|
+
|
|
123
|
+
// Which client asked for which host. Two projects may map the same domain, so
|
|
124
|
+
// a mapping is only removed once its last holder is gone.
|
|
125
|
+
const hostOwners = new Map<string, Set<object>>()
|
|
126
|
+
|
|
127
|
+
const hostId = (ip: string, host: string): string => `${ip} ${host.toLowerCase()}`
|
|
128
|
+
|
|
129
|
+
function addHostOwner(client: object | undefined, ip: string, host: string): void {
|
|
130
|
+
const id = hostId(ip, host)
|
|
131
|
+
let owners = hostOwners.get(id)
|
|
132
|
+
if (!owners) {
|
|
133
|
+
owners = new Set()
|
|
134
|
+
hostOwners.set(id, owners)
|
|
135
|
+
}
|
|
136
|
+
owners.add(client ?? deps)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** True when no other client still needs this mapping. */
|
|
140
|
+
function releaseHostOwner(client: object | undefined, ip: string, host: string): boolean {
|
|
141
|
+
const id = hostId(ip, host)
|
|
142
|
+
const owners = hostOwners.get(id)
|
|
143
|
+
if (!owners)
|
|
144
|
+
return true
|
|
145
|
+
|
|
146
|
+
owners.delete(client ?? deps)
|
|
147
|
+
if (owners.size > 0)
|
|
148
|
+
return false
|
|
149
|
+
|
|
150
|
+
hostOwners.delete(id)
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const broadcast = (event: HelperEvent): void => {
|
|
155
|
+
const line = serializeMessage(event)
|
|
156
|
+
for (const client of clients) {
|
|
157
|
+
const socket = client as Socket
|
|
158
|
+
if (typeof socket.write === 'function' && !socket.destroyed)
|
|
159
|
+
socket.write(line)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function stopCaddy(): Promise<void> {
|
|
164
|
+
const current = caddy
|
|
165
|
+
caddy = undefined
|
|
166
|
+
if (current) {
|
|
167
|
+
try {
|
|
168
|
+
current.kill('SIGTERM')
|
|
169
|
+
}
|
|
170
|
+
catch {}
|
|
171
|
+
}
|
|
172
|
+
await deps.stopCaddy().catch(() => {})
|
|
173
|
+
|
|
174
|
+
// Give Caddy's files back to the developer: the next start runs as root
|
|
175
|
+
// again, but the developer must be able to read the PKI in between.
|
|
176
|
+
if (deps.caddyStoragePath && deps.storageOwner)
|
|
177
|
+
await reclaimTree(deps.caddyStoragePath, deps.storageOwner.uid, deps.storageOwner.gid).catch(() => {})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function dispose(): Promise<void> {
|
|
181
|
+
if (!teardown) {
|
|
182
|
+
const startedAt = generation
|
|
183
|
+
teardown = (async () => {
|
|
184
|
+
try {
|
|
185
|
+
// A client may have attached while this teardown was queued; its
|
|
186
|
+
// entries belong to the new session and must survive.
|
|
187
|
+
if (generation === startedAt) {
|
|
188
|
+
const edited = removeMarkedHosts(deps.readHosts())
|
|
189
|
+
if (edited.changed) {
|
|
190
|
+
deps.writeHosts(edited.content)
|
|
191
|
+
await deps.flushDns().catch(() => {})
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
deps.log(`hosts cleanup failed: ${(error as Error).message}`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The new session owns Caddy now; stopping it here would kill a server
|
|
200
|
+
// this generation never started.
|
|
201
|
+
if (generation === startedAt)
|
|
202
|
+
await stopCaddy()
|
|
203
|
+
})()
|
|
204
|
+
}
|
|
205
|
+
return teardown
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function sweepStaleEntries(): Promise<boolean> {
|
|
209
|
+
try {
|
|
210
|
+
const edited = removeMarkedHosts(deps.readHosts())
|
|
211
|
+
if (!edited.changed)
|
|
212
|
+
return false
|
|
213
|
+
deps.writeHosts(edited.content)
|
|
214
|
+
await flushDnsBestEffort(deps)
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
deps.log(`stale hosts cleanup failed: ${(error as Error).message}`)
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function handle(request: HelperRequest, client?: object): Promise<HelperResponse> {
|
|
224
|
+
try {
|
|
225
|
+
switch (request.method) {
|
|
226
|
+
case 'ping':
|
|
227
|
+
return { id: request.id, ok: true, result: { pid: process.pid } }
|
|
228
|
+
|
|
229
|
+
case 'hosts.add': {
|
|
230
|
+
const { ip, host } = request.params
|
|
231
|
+
if (!isSafeIp(ip) || !isSafeHost(host))
|
|
232
|
+
return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
|
|
233
|
+
|
|
234
|
+
const edited = addHost(deps.readHosts(), ip, host)
|
|
235
|
+
if (edited.changed)
|
|
236
|
+
deps.writeHosts(edited.content)
|
|
237
|
+
addHostOwner(client, ip, host)
|
|
238
|
+
await flushDnsBestEffort(deps)
|
|
239
|
+
return { id: request.id, ok: true }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'hosts.remove': {
|
|
243
|
+
const { ip, host } = request.params
|
|
244
|
+
if (!isSafeIp(ip) || !isSafeHost(host))
|
|
245
|
+
return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
|
|
246
|
+
|
|
247
|
+
// Another project may still need this domain; only the last holder
|
|
248
|
+
// removes the mapping.
|
|
249
|
+
const last = releaseHostOwner(client, ip, host)
|
|
250
|
+
if (!last)
|
|
251
|
+
return { id: request.id, ok: true, result: { kept: true } }
|
|
252
|
+
|
|
253
|
+
const edited = removeHost(deps.readHosts(), ip, host)
|
|
254
|
+
if (edited.changed)
|
|
255
|
+
deps.writeHosts(edited.content)
|
|
256
|
+
await flushDnsBestEffort(deps)
|
|
257
|
+
return { id: request.id, ok: true }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case 'port.free': {
|
|
261
|
+
const { port } = request.params
|
|
262
|
+
if (!isAllowedFreePort(port))
|
|
263
|
+
return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } }
|
|
264
|
+
|
|
265
|
+
// Caddy belongs to this helper, so a port sweep must never kill it:
|
|
266
|
+
// the pid is the one serving every other project's domains.
|
|
267
|
+
const killed = await deps.freePort(port, caddy?.pid)
|
|
268
|
+
return { id: request.id, ok: true, result: { killed } }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'caddy.start': {
|
|
272
|
+
// Idempotent: several dev servers share one Caddy, so a second start
|
|
273
|
+
// must attach to the running process instead of restarting it.
|
|
274
|
+
if (caddy && caddy.running !== false)
|
|
275
|
+
return { id: request.id, ok: true, result: { pid: caddy.pid, started: false } }
|
|
276
|
+
|
|
277
|
+
caddy = deps.startCaddy(broadcast)
|
|
278
|
+
return { id: request.id, ok: true, result: { pid: caddy.pid, started: true } }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case 'caddy.stop': {
|
|
282
|
+
await stopCaddy()
|
|
283
|
+
return { id: request.id, ok: true }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'ca.trust': {
|
|
287
|
+
const result = await deps.trustLocalCa()
|
|
288
|
+
return { id: request.id, ok: true, result }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
default:
|
|
292
|
+
return { id: (request as HelperRequest).id, ok: false, error: { message: 'unknown method' } }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
return { id: request.id, ok: false, error: { message: (error as Error).message } }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
handle,
|
|
302
|
+
attach(client) {
|
|
303
|
+
clients.add(client)
|
|
304
|
+
// Only the newest generation may clean up; an in-flight teardown started
|
|
305
|
+
// before this call is abandoned rather than cancelled mid-write.
|
|
306
|
+
generation++
|
|
307
|
+
teardown = undefined
|
|
308
|
+
},
|
|
309
|
+
async detach(client) {
|
|
310
|
+
clients.delete(client)
|
|
311
|
+
|
|
312
|
+
// Drop only the mappings this client was the last holder of, so a project
|
|
313
|
+
// that exits leaves the domains another project still serves alone.
|
|
314
|
+
for (const [id, owners] of [...hostOwners]) {
|
|
315
|
+
if (!owners.delete(client) || owners.size > 0)
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
hostOwners.delete(id)
|
|
319
|
+
const [ip, host] = id.split(' ')
|
|
320
|
+
try {
|
|
321
|
+
const edited = removeHost(deps.readHosts(), ip, host)
|
|
322
|
+
if (edited.changed) {
|
|
323
|
+
deps.writeHosts(edited.content)
|
|
324
|
+
await flushDnsBestEffort(deps)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
deps.log(`hosts cleanup failed: ${(error as Error).message}`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (clients.size === 0)
|
|
333
|
+
await dispose()
|
|
334
|
+
},
|
|
335
|
+
dispose,
|
|
336
|
+
sweepStaleEntries,
|
|
337
|
+
clientCount: () => clients.size,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface NodeHelperDepsOptions {
|
|
342
|
+
hostsPath?: string
|
|
343
|
+
caddyBin: string
|
|
344
|
+
caddyConfigPath: string
|
|
345
|
+
/** Admin API socket; `ca.trust` talks to it instead of the TCP default. */
|
|
346
|
+
caddyAdminSocket?: string
|
|
347
|
+
caddyCwd?: string
|
|
348
|
+
caddyEnv?: Record<string, string>
|
|
349
|
+
/** Caddy storage directory; handed back to the user when Caddy stops. */
|
|
350
|
+
caddyStoragePath?: string
|
|
351
|
+
/** Owner that receives the storage directory after Caddy stops. */
|
|
352
|
+
storageOwner?: { uid: number, gid: number }
|
|
353
|
+
log?: (message: string) => void
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Best-effort recursive chown, skipping anything it cannot read. */
|
|
357
|
+
async function reclaimTree(target: string, uid: number, gid: number): Promise<string[]> {
|
|
358
|
+
const reclaimed: string[] = []
|
|
359
|
+
|
|
360
|
+
const walk = (path: string): void => {
|
|
361
|
+
let stats
|
|
362
|
+
try {
|
|
363
|
+
// `lstat`: a symlink must never redirect a root chown to its target.
|
|
364
|
+
stats = lstatSync(path)
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
lchownSync(path, uid, gid)
|
|
372
|
+
reclaimed.push(path)
|
|
373
|
+
}
|
|
374
|
+
catch {}
|
|
375
|
+
|
|
376
|
+
if (!stats.isDirectory())
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
let entries: string[]
|
|
380
|
+
try {
|
|
381
|
+
entries = readdirSync(path)
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (const entry of entries)
|
|
388
|
+
walk(join(path, entry))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
walk(target)
|
|
392
|
+
return reclaimed
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Installs the Caddy root CA using helper-owned commands. */
|
|
396
|
+
async function trustLocalCaWithSystemTools(
|
|
397
|
+
caddyBin: string,
|
|
398
|
+
adminSocket: string | undefined,
|
|
399
|
+
): Promise<{ trusted: boolean, output: string }> {
|
|
400
|
+
// `caddy trust` reads the CA through the admin API, which only exists while
|
|
401
|
+
// Caddy is serving; the plugin calls this after Caddy is up. The admin API is
|
|
402
|
+
// a unix socket when the template enabled one, so the address follows it
|
|
403
|
+
// instead of the old TCP default.
|
|
404
|
+
const address = adminSocket ? `unix/${adminSocket}` : '127.0.0.1:2019'
|
|
405
|
+
try {
|
|
406
|
+
const { stdout, stderr } = await execFileAsync(
|
|
407
|
+
caddyBin,
|
|
408
|
+
['trust', '--ca', 'local', '--address', address],
|
|
409
|
+
{ timeout: 120_000 },
|
|
410
|
+
)
|
|
411
|
+
return { trusted: true, output: `${stdout}${stderr}`.trim() }
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
const output = String((error as { stdout?: string }).stdout ?? '') + String((error as { stderr?: string }).stderr ?? '')
|
|
415
|
+
if (/already|not modified|no changes/i.test(output))
|
|
416
|
+
return { trusted: true, output: output.trim() }
|
|
417
|
+
throw new Error(output.trim() || (error as Error).message)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Production wiring for the helper process. */
|
|
422
|
+
export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps {
|
|
423
|
+
const hostsPath = options.hostsPath ?? HOSTS_PATH
|
|
424
|
+
const log = options.log ?? ((message: string) => process.stdout.write(`${message}\n`))
|
|
425
|
+
let caddy: CaddyChild | undefined
|
|
426
|
+
|
|
427
|
+
// Caddy runs in its own session, so it no longer dies with this process. The
|
|
428
|
+
// `exit` hook is the last chance to take it down; it must stay synchronous,
|
|
429
|
+
// which `kill` is. Nothing runs after SIGKILL, and that case is covered by
|
|
430
|
+
// the next start freeing the port.
|
|
431
|
+
process.on('exit', () => {
|
|
432
|
+
if (caddy?.pid === undefined)
|
|
433
|
+
return
|
|
434
|
+
try {
|
|
435
|
+
process.kill(caddy.pid, 'SIGTERM')
|
|
436
|
+
}
|
|
437
|
+
catch {}
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
readHosts: () => readFileSync(hostsPath, 'utf8'),
|
|
442
|
+
writeHosts: (content) => {
|
|
443
|
+
writeFileSync(hostsPath, content)
|
|
444
|
+
},
|
|
445
|
+
flushDns: async () => {
|
|
446
|
+
const candidates = FLUSH_COMMANDS[platform()] ?? [...FLUSH_FALLBACK]
|
|
447
|
+
let lastError: Error | undefined
|
|
448
|
+
|
|
449
|
+
for (const candidate of candidates) {
|
|
450
|
+
try {
|
|
451
|
+
await execFileAsync(candidate.file, candidate.args)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
lastError = error as Error
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
throw lastError ?? new Error('no DNS cache flush command for this platform')
|
|
460
|
+
},
|
|
461
|
+
freePort: async (port, keepPid) => {
|
|
462
|
+
// `lsof` exits with 1 when nothing matches, which is the normal case for
|
|
463
|
+
// a free port; only its stdout carries the answer.
|
|
464
|
+
const pids = platform() === 'win32'
|
|
465
|
+
? parseWindowsListeningPids((await execFileAsync('netstat', ['-ano'])).stdout, port)
|
|
466
|
+
: parseListeningPids((await execFileQuiet('lsof', [`-ti:${port}`, '-sTCP:LISTEN'])).stdout)
|
|
467
|
+
|
|
468
|
+
const killed: number[] = []
|
|
469
|
+
for (const pid of pids) {
|
|
470
|
+
// Never kill the helper itself or the Caddy it owns: that Caddy serves
|
|
471
|
+
// every project sharing this helper, not just the caller's.
|
|
472
|
+
if (pid === process.pid || (keepPid !== undefined && pid === keepPid))
|
|
473
|
+
continue
|
|
474
|
+
try {
|
|
475
|
+
process.kill(pid, 'SIGTERM')
|
|
476
|
+
killed.push(pid)
|
|
477
|
+
}
|
|
478
|
+
catch {}
|
|
479
|
+
}
|
|
480
|
+
return killed
|
|
481
|
+
},
|
|
482
|
+
caddyBin: options.caddyBin,
|
|
483
|
+
caddyConfigPath: options.caddyConfigPath,
|
|
484
|
+
caddyCwd: options.caddyCwd ?? process.cwd(),
|
|
485
|
+
caddyEnv: options.caddyEnv ?? {},
|
|
486
|
+
trustLocalCa: () => trustLocalCaWithSystemTools(options.caddyBin, options.caddyAdminSocket),
|
|
487
|
+
caddyStoragePath: options.caddyStoragePath,
|
|
488
|
+
storageOwner: options.storageOwner,
|
|
489
|
+
startCaddy: (emit) => {
|
|
490
|
+
// `--watch` makes Caddy reload when another project rewrites the shared
|
|
491
|
+
// Caddyfile. Without it the second project's route never takes effect.
|
|
492
|
+
//
|
|
493
|
+
// `detached` puts Caddy in its own session. It shares one Caddy between
|
|
494
|
+
// projects, so the Ctrl+C that stops one dev server must not reach it:
|
|
495
|
+
// the whole foreground process group gets SIGINT, and Caddy exits on
|
|
496
|
+
// SIGINT. `kill()` still targets the pid, and the exit event still fires.
|
|
497
|
+
// Windows has no process groups to escape and `detached` would open a
|
|
498
|
+
// console window there, so it stays off.
|
|
499
|
+
const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath, '--watch'], {
|
|
500
|
+
cwd: options.caddyCwd ?? process.cwd(),
|
|
501
|
+
env: { ...process.env, ...options.caddyEnv },
|
|
502
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
503
|
+
detached: platform() !== 'win32',
|
|
504
|
+
})
|
|
505
|
+
const handle: CaddyChild & { running?: boolean } = {
|
|
506
|
+
pid: child.pid,
|
|
507
|
+
kill: signal => child.kill(signal),
|
|
508
|
+
running: true,
|
|
509
|
+
}
|
|
510
|
+
child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() }))
|
|
511
|
+
child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() }))
|
|
512
|
+
child.on('exit', (code, signal) => {
|
|
513
|
+
// Recorded before broadcasting so a later `caddy.start` knows it has to
|
|
514
|
+
// spawn again rather than reuse a dead process.
|
|
515
|
+
handle.running = false
|
|
516
|
+
emit({ type: 'event', kind: 'caddy.exit', code, signal })
|
|
517
|
+
})
|
|
518
|
+
caddy = handle
|
|
519
|
+
return handle
|
|
520
|
+
},
|
|
521
|
+
stopCaddy: async () => {
|
|
522
|
+
const current = caddy
|
|
523
|
+
caddy = undefined
|
|
524
|
+
if (!current)
|
|
525
|
+
return
|
|
526
|
+
try {
|
|
527
|
+
current.kill('SIGTERM')
|
|
528
|
+
}
|
|
529
|
+
catch {}
|
|
530
|
+
},
|
|
531
|
+
log,
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Serves requests on an authenticated connection. When the client disappears —
|
|
537
|
+
* including a `SIGKILL` of the plugin process — the socket closes and `dispose`
|
|
538
|
+
* restores the machine state.
|
|
539
|
+
*/
|
|
540
|
+
export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (message: string) => void): void {
|
|
541
|
+
let buffer = ''
|
|
542
|
+
runtime.attach(socket)
|
|
543
|
+
|
|
544
|
+
socket.on('data', (chunk: Buffer) => {
|
|
545
|
+
buffer += chunk.toString('utf8')
|
|
546
|
+
let index = buffer.indexOf('\n')
|
|
547
|
+
while (index >= 0) {
|
|
548
|
+
const line = buffer.slice(0, index)
|
|
549
|
+
buffer = buffer.slice(index + 1)
|
|
550
|
+
index = buffer.indexOf('\n')
|
|
551
|
+
|
|
552
|
+
if (!line.trim())
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
let request: HelperRequest
|
|
556
|
+
try {
|
|
557
|
+
request = JSON.parse(line) as HelperRequest
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
log(`invalid request: ${(error as Error).message}`)
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
void runtime.handle(request, socket).then((response) => {
|
|
565
|
+
if (!socket.destroyed)
|
|
566
|
+
socket.write(serializeMessage(response))
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
socket.on('error', (error) => {
|
|
572
|
+
log(`connection error: ${error.message}`)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
socket.on('close', () => {
|
|
576
|
+
void runtime.detach(socket)
|
|
577
|
+
})
|
|
578
|
+
}
|