unplugin-https-reverse-proxy 4.0.4 → 4.1.0-beta.2
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-5R33PUJP.js → chunk-7CA3EIIX.js} +681 -223
- package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
- package/dist/chunk-RKHJN6KY.js +53 -0
- package/dist/esbuild.cjs +781 -300
- package/dist/esbuild.js +3 -2
- package/dist/index.cjs +781 -300
- package/dist/index.js +3 -2
- package/dist/nuxt.cjs +779 -290
- package/dist/nuxt.js +5 -9
- package/dist/privileged-helper.cjs +668 -0
- package/dist/privileged-helper.d.cts +2 -0
- package/dist/privileged-helper.d.ts +2 -0
- package/dist/privileged-helper.js +641 -0
- package/dist/rollup.cjs +781 -300
- package/dist/rollup.js +3 -2
- package/dist/rspack.cjs +781 -300
- package/dist/rspack.js +3 -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 +781 -300
- package/dist/vite.js +3 -2
- package/dist/webpack.cjs +781 -300
- package/dist/webpack.js +3 -2
- package/package.json +1 -4
- package/src/caddy/caddyfile.ts +1 -0
- package/src/caddy/index.ts +183 -110
- package/src/caddy/privileged.ts +118 -0
- package/src/caddy/utils.ts +0 -43
- package/src/index.ts +1 -15
- package/src/nuxt.ts +1 -6
- package/src/privileged/bootstrap.ts +174 -0
- package/src/privileged/client.ts +287 -0
- package/src/privileged/entry.ts +45 -0
- package/src/privileged/hosts-file.ts +142 -0
- package/src/privileged/launch.ts +193 -0
- package/src/privileged/ports.ts +45 -0
- package/src/privileged/protocol.ts +111 -0
- package/src/privileged/server.ts +465 -0
- package/src/privileged/session.ts +114 -0
- package/src/privileged/validation.ts +38 -0
- package/src/privileged/windows-elevation.ts +80 -0
- package/src/privileged-helper.ts +109 -0
- package/src/utils.ts +0 -28
- package/src/host/index.ts +0 -52
|
@@ -0,0 +1,465 @@
|
|
|
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
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Everything privileged the helper can do.
|
|
53
|
+
*
|
|
54
|
+
* Paths and arguments are fixed at helper start-up; the client only chooses
|
|
55
|
+
* when* something happens, never *what* runs. That keeps the privileged
|
|
56
|
+
* surface to a handful of primitives even if the socket is reached by an
|
|
57
|
+
* unexpected local process.
|
|
58
|
+
*/
|
|
59
|
+
export interface HelperDeps {
|
|
60
|
+
readHosts: () => string
|
|
61
|
+
writeHosts: (content: string) => void
|
|
62
|
+
flushDns: () => Promise<void>
|
|
63
|
+
freePort: (port: number) => Promise<number[]>
|
|
64
|
+
/** Caddy binary and the Caddyfile it must load, both fixed at start-up. */
|
|
65
|
+
caddyBin: string
|
|
66
|
+
caddyConfigPath: string
|
|
67
|
+
caddyCwd: string
|
|
68
|
+
caddyEnv: Record<string, string>
|
|
69
|
+
/** Installs the local root CA using a helper-owned command, not a client path. */
|
|
70
|
+
trustLocalCa: () => Promise<{ trusted: boolean, output: string }>
|
|
71
|
+
/** Caddy writes its PKI as root; this tree goes back to the user afterwards. */
|
|
72
|
+
caddyStoragePath?: string
|
|
73
|
+
storageOwner?: { uid: number, gid: number }
|
|
74
|
+
/** Starts Caddy with the configuration fixed at helper start-up. */
|
|
75
|
+
startCaddy: (emit: (event: HelperEvent) => void) => CaddyChild
|
|
76
|
+
stopCaddy: () => Promise<void>
|
|
77
|
+
log: (message: string) => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolver caching only affects how fast a new entry is picked up, so a failed
|
|
82
|
+
* flush is logged instead of rolling back a hosts edit that already succeeded.
|
|
83
|
+
*/
|
|
84
|
+
async function flushDnsBestEffort(deps: HelperDeps): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
await deps.flushDns()
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
deps.log(`dns flush failed: ${(error as Error).message}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface HelperRuntime {
|
|
94
|
+
handle: (request: HelperRequest) => Promise<HelperResponse>
|
|
95
|
+
/** Registers a connected client so Caddy output reaches it. */
|
|
96
|
+
attach: (client: object) => void
|
|
97
|
+
/** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */
|
|
98
|
+
detach: (client: object) => Promise<void>
|
|
99
|
+
/** Runs the teardown once, regardless of how many clients were attached. */
|
|
100
|
+
dispose: () => Promise<void>
|
|
101
|
+
/** Removes hosts entries a previous crashed session left behind. */
|
|
102
|
+
sweepStaleEntries: () => Promise<boolean>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Request router shared by the helper process and its tests. Kept free of
|
|
107
|
+
* sockets so tests can drive it against a temp hosts file and a fake Caddy.
|
|
108
|
+
*/
|
|
109
|
+
export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
110
|
+
const clients = new Set<object>()
|
|
111
|
+
let teardown: Promise<void> | undefined
|
|
112
|
+
// Bumped whenever a client attaches. A teardown that started before the bump
|
|
113
|
+
// belongs to a previous generation and must not touch the new session.
|
|
114
|
+
let generation = 0
|
|
115
|
+
let caddy: CaddyChild | undefined
|
|
116
|
+
|
|
117
|
+
const broadcast = (event: HelperEvent): void => {
|
|
118
|
+
const line = serializeMessage(event)
|
|
119
|
+
for (const client of clients) {
|
|
120
|
+
const socket = client as Socket
|
|
121
|
+
if (typeof socket.write === 'function' && !socket.destroyed)
|
|
122
|
+
socket.write(line)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function stopCaddy(): Promise<void> {
|
|
127
|
+
const current = caddy
|
|
128
|
+
caddy = undefined
|
|
129
|
+
if (current) {
|
|
130
|
+
try {
|
|
131
|
+
current.kill('SIGTERM')
|
|
132
|
+
}
|
|
133
|
+
catch {}
|
|
134
|
+
}
|
|
135
|
+
await deps.stopCaddy().catch(() => {})
|
|
136
|
+
|
|
137
|
+
// Give Caddy's files back to the developer: the next start runs as root
|
|
138
|
+
// again, but the developer must be able to read the PKI in between.
|
|
139
|
+
if (deps.caddyStoragePath && deps.storageOwner)
|
|
140
|
+
await reclaimTree(deps.caddyStoragePath, deps.storageOwner.uid, deps.storageOwner.gid).catch(() => {})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function dispose(): Promise<void> {
|
|
144
|
+
if (!teardown) {
|
|
145
|
+
const startedAt = generation
|
|
146
|
+
teardown = (async () => {
|
|
147
|
+
try {
|
|
148
|
+
// A client may have attached while this teardown was queued; its
|
|
149
|
+
// entries belong to the new session and must survive.
|
|
150
|
+
if (generation === startedAt) {
|
|
151
|
+
const edited = removeMarkedHosts(deps.readHosts())
|
|
152
|
+
if (edited.changed) {
|
|
153
|
+
deps.writeHosts(edited.content)
|
|
154
|
+
await deps.flushDns().catch(() => {})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
deps.log(`hosts cleanup failed: ${(error as Error).message}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The new session owns Caddy now; stopping it here would kill a server
|
|
163
|
+
// this generation never started.
|
|
164
|
+
if (generation === startedAt)
|
|
165
|
+
await stopCaddy()
|
|
166
|
+
})()
|
|
167
|
+
}
|
|
168
|
+
return teardown
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function sweepStaleEntries(): Promise<boolean> {
|
|
172
|
+
try {
|
|
173
|
+
const edited = removeMarkedHosts(deps.readHosts())
|
|
174
|
+
if (!edited.changed)
|
|
175
|
+
return false
|
|
176
|
+
deps.writeHosts(edited.content)
|
|
177
|
+
await flushDnsBestEffort(deps)
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
deps.log(`stale hosts cleanup failed: ${(error as Error).message}`)
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function handle(request: HelperRequest): Promise<HelperResponse> {
|
|
187
|
+
try {
|
|
188
|
+
switch (request.method) {
|
|
189
|
+
case 'ping':
|
|
190
|
+
return { id: request.id, ok: true, result: { pid: process.pid } }
|
|
191
|
+
|
|
192
|
+
case 'hosts.add': {
|
|
193
|
+
const { ip, host } = request.params
|
|
194
|
+
if (!isSafeIp(ip) || !isSafeHost(host))
|
|
195
|
+
return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
|
|
196
|
+
|
|
197
|
+
const edited = addHost(deps.readHosts(), ip, host)
|
|
198
|
+
if (edited.changed)
|
|
199
|
+
deps.writeHosts(edited.content)
|
|
200
|
+
await flushDnsBestEffort(deps)
|
|
201
|
+
return { id: request.id, ok: true }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case 'hosts.remove': {
|
|
205
|
+
const { ip, host } = request.params
|
|
206
|
+
if (!isSafeIp(ip) || !isSafeHost(host))
|
|
207
|
+
return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
|
|
208
|
+
|
|
209
|
+
const edited = removeHost(deps.readHosts(), ip, host)
|
|
210
|
+
if (edited.changed)
|
|
211
|
+
deps.writeHosts(edited.content)
|
|
212
|
+
await flushDnsBestEffort(deps)
|
|
213
|
+
return { id: request.id, ok: true }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case 'port.free': {
|
|
217
|
+
const { port } = request.params
|
|
218
|
+
if (!isAllowedFreePort(port))
|
|
219
|
+
return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } }
|
|
220
|
+
|
|
221
|
+
const killed = await deps.freePort(port)
|
|
222
|
+
return { id: request.id, ok: true, result: { killed } }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case 'caddy.start': {
|
|
226
|
+
if (caddy)
|
|
227
|
+
await stopCaddy()
|
|
228
|
+
|
|
229
|
+
caddy = deps.startCaddy(broadcast)
|
|
230
|
+
return { id: request.id, ok: true, result: { pid: caddy.pid } }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
case 'caddy.stop': {
|
|
234
|
+
await stopCaddy()
|
|
235
|
+
return { id: request.id, ok: true }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case 'ca.trust': {
|
|
239
|
+
const result = await deps.trustLocalCa()
|
|
240
|
+
return { id: request.id, ok: true, result }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
default:
|
|
244
|
+
return { id: (request as HelperRequest).id, ok: false, error: { message: 'unknown method' } }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
return { id: request.id, ok: false, error: { message: (error as Error).message } }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
handle,
|
|
254
|
+
attach(client) {
|
|
255
|
+
clients.add(client)
|
|
256
|
+
// Only the newest generation may clean up; an in-flight teardown started
|
|
257
|
+
// before this call is abandoned rather than cancelled mid-write.
|
|
258
|
+
generation++
|
|
259
|
+
teardown = undefined
|
|
260
|
+
},
|
|
261
|
+
async detach(client) {
|
|
262
|
+
clients.delete(client)
|
|
263
|
+
if (clients.size === 0)
|
|
264
|
+
await dispose()
|
|
265
|
+
},
|
|
266
|
+
dispose,
|
|
267
|
+
sweepStaleEntries,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface NodeHelperDepsOptions {
|
|
272
|
+
hostsPath?: string
|
|
273
|
+
caddyBin: string
|
|
274
|
+
caddyConfigPath: string
|
|
275
|
+
caddyCwd?: string
|
|
276
|
+
caddyEnv?: Record<string, string>
|
|
277
|
+
/** Caddy storage directory; handed back to the user when Caddy stops. */
|
|
278
|
+
caddyStoragePath?: string
|
|
279
|
+
/** Owner that receives the storage directory after Caddy stops. */
|
|
280
|
+
storageOwner?: { uid: number, gid: number }
|
|
281
|
+
log?: (message: string) => void
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Best-effort recursive chown, skipping anything it cannot read. */
|
|
285
|
+
async function reclaimTree(target: string, uid: number, gid: number): Promise<string[]> {
|
|
286
|
+
const reclaimed: string[] = []
|
|
287
|
+
|
|
288
|
+
const walk = (path: string): void => {
|
|
289
|
+
let stats
|
|
290
|
+
try {
|
|
291
|
+
// `lstat`: a symlink must never redirect a root chown to its target.
|
|
292
|
+
stats = lstatSync(path)
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
lchownSync(path, uid, gid)
|
|
300
|
+
reclaimed.push(path)
|
|
301
|
+
}
|
|
302
|
+
catch {}
|
|
303
|
+
|
|
304
|
+
if (!stats.isDirectory())
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
let entries: string[]
|
|
308
|
+
try {
|
|
309
|
+
entries = readdirSync(path)
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const entry of entries)
|
|
316
|
+
walk(join(path, entry))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
walk(target)
|
|
320
|
+
return reclaimed
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Installs the Caddy root CA using helper-owned commands. */
|
|
324
|
+
async function trustLocalCaWithSystemTools(caddyBin: string): Promise<{ trusted: boolean, output: string }> {
|
|
325
|
+
// `caddy trust` reads the CA through the admin API, which only exists while
|
|
326
|
+
// Caddy is serving; the plugin calls this after Caddy is up.
|
|
327
|
+
try {
|
|
328
|
+
const { stdout, stderr } = await execFileAsync(
|
|
329
|
+
caddyBin,
|
|
330
|
+
['trust', '--ca', 'local', '--address', '127.0.0.1:2019'],
|
|
331
|
+
{ timeout: 120_000 },
|
|
332
|
+
)
|
|
333
|
+
return { trusted: true, output: `${stdout}${stderr}`.trim() }
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
const output = String((error as { stdout?: string }).stdout ?? '') + String((error as { stderr?: string }).stderr ?? '')
|
|
337
|
+
if (/already|not modified|no changes/i.test(output))
|
|
338
|
+
return { trusted: true, output: output.trim() }
|
|
339
|
+
throw new Error(output.trim() || (error as Error).message)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Production wiring for the helper process. */
|
|
344
|
+
export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps {
|
|
345
|
+
const hostsPath = options.hostsPath ?? HOSTS_PATH
|
|
346
|
+
const log = options.log ?? ((message: string) => process.stdout.write(`${message}\n`))
|
|
347
|
+
let caddy: CaddyChild | undefined
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
readHosts: () => readFileSync(hostsPath, 'utf8'),
|
|
351
|
+
writeHosts: (content) => {
|
|
352
|
+
writeFileSync(hostsPath, content)
|
|
353
|
+
},
|
|
354
|
+
flushDns: async () => {
|
|
355
|
+
const candidates = FLUSH_COMMANDS[platform()] ?? [...FLUSH_FALLBACK]
|
|
356
|
+
let lastError: Error | undefined
|
|
357
|
+
|
|
358
|
+
for (const candidate of candidates) {
|
|
359
|
+
try {
|
|
360
|
+
await execFileAsync(candidate.file, candidate.args)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
lastError = error as Error
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
throw lastError ?? new Error('no DNS cache flush command for this platform')
|
|
369
|
+
},
|
|
370
|
+
freePort: async (port) => {
|
|
371
|
+
// `lsof` exits with 1 when nothing matches, which is the normal case for
|
|
372
|
+
// a free port; only its stdout carries the answer.
|
|
373
|
+
const pids = platform() === 'win32'
|
|
374
|
+
? parseWindowsListeningPids((await execFileAsync('netstat', ['-ano'])).stdout, port)
|
|
375
|
+
: parseListeningPids((await execFileQuiet('lsof', [`-ti:${port}`, '-sTCP:LISTEN'])).stdout)
|
|
376
|
+
|
|
377
|
+
const killed: number[] = []
|
|
378
|
+
for (const pid of pids) {
|
|
379
|
+
if (pid === process.pid)
|
|
380
|
+
continue
|
|
381
|
+
try {
|
|
382
|
+
process.kill(pid, 'SIGTERM')
|
|
383
|
+
killed.push(pid)
|
|
384
|
+
}
|
|
385
|
+
catch {}
|
|
386
|
+
}
|
|
387
|
+
return killed
|
|
388
|
+
},
|
|
389
|
+
caddyBin: options.caddyBin,
|
|
390
|
+
caddyConfigPath: options.caddyConfigPath,
|
|
391
|
+
caddyCwd: options.caddyCwd ?? process.cwd(),
|
|
392
|
+
caddyEnv: options.caddyEnv ?? {},
|
|
393
|
+
trustLocalCa: () => trustLocalCaWithSystemTools(options.caddyBin),
|
|
394
|
+
caddyStoragePath: options.caddyStoragePath,
|
|
395
|
+
storageOwner: options.storageOwner,
|
|
396
|
+
startCaddy: (emit) => {
|
|
397
|
+
const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath], {
|
|
398
|
+
cwd: options.caddyCwd ?? process.cwd(),
|
|
399
|
+
env: { ...process.env, ...options.caddyEnv },
|
|
400
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
401
|
+
})
|
|
402
|
+
child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() }))
|
|
403
|
+
child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() }))
|
|
404
|
+
child.on('exit', (code, signal) => emit({ type: 'event', kind: 'caddy.exit', code, signal }))
|
|
405
|
+
caddy = child as unknown as CaddyChild
|
|
406
|
+
return caddy
|
|
407
|
+
},
|
|
408
|
+
stopCaddy: async () => {
|
|
409
|
+
const current = caddy
|
|
410
|
+
caddy = undefined
|
|
411
|
+
if (!current)
|
|
412
|
+
return
|
|
413
|
+
try {
|
|
414
|
+
current.kill('SIGTERM')
|
|
415
|
+
}
|
|
416
|
+
catch {}
|
|
417
|
+
},
|
|
418
|
+
log,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Serves requests on an authenticated connection. When the client disappears —
|
|
424
|
+
* including a `SIGKILL` of the plugin process — the socket closes and `dispose`
|
|
425
|
+
* restores the machine state.
|
|
426
|
+
*/
|
|
427
|
+
export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (message: string) => void): void {
|
|
428
|
+
let buffer = ''
|
|
429
|
+
runtime.attach(socket)
|
|
430
|
+
|
|
431
|
+
socket.on('data', (chunk: Buffer) => {
|
|
432
|
+
buffer += chunk.toString('utf8')
|
|
433
|
+
let index = buffer.indexOf('\n')
|
|
434
|
+
while (index >= 0) {
|
|
435
|
+
const line = buffer.slice(0, index)
|
|
436
|
+
buffer = buffer.slice(index + 1)
|
|
437
|
+
index = buffer.indexOf('\n')
|
|
438
|
+
|
|
439
|
+
if (!line.trim())
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
let request: HelperRequest
|
|
443
|
+
try {
|
|
444
|
+
request = JSON.parse(line) as HelperRequest
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
log(`invalid request: ${(error as Error).message}`)
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
void runtime.handle(request).then((response) => {
|
|
452
|
+
if (!socket.destroyed)
|
|
453
|
+
socket.write(serializeMessage(response))
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
socket.on('error', (error) => {
|
|
459
|
+
log(`connection error: ${error.message}`)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
socket.on('close', () => {
|
|
463
|
+
void runtime.detach(socket)
|
|
464
|
+
})
|
|
465
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { PrivilegedClient } from './client'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { platform } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { connectExistingHelper, createHelperToken, startPrivilegedHelper } from './launch'
|
|
8
|
+
|
|
9
|
+
export interface SessionScope {
|
|
10
|
+
/** Runtime directory shared by the plugin, e.g. `TEMP_DIR`. */
|
|
11
|
+
runtimeDir: string
|
|
12
|
+
/** Directory of the running bundle; used to locate the helper entry. */
|
|
13
|
+
helperEntry: string
|
|
14
|
+
helperArgs?: string[]
|
|
15
|
+
env?: NodeJS.ProcessEnv
|
|
16
|
+
sudoCommand?: string
|
|
17
|
+
/** Skips the interactive credential check; used by tests. */
|
|
18
|
+
skipAuth?: boolean
|
|
19
|
+
/** Runs the helper as the current user; used by tests without privileges. */
|
|
20
|
+
direct?: boolean
|
|
21
|
+
/** Caddy storage directory; the helper hands it back to the user on stop. */
|
|
22
|
+
caddyStoragePath?: string
|
|
23
|
+
/** Caddy binary the helper may run; the client cannot change it later. */
|
|
24
|
+
caddyBin: string
|
|
25
|
+
/** Caddyfile the helper may load. */
|
|
26
|
+
caddyConfigPath: string
|
|
27
|
+
log?: (message: string) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OpenedSession {
|
|
31
|
+
client: PrivilegedClient
|
|
32
|
+
/** True when an already-running helper was reused. */
|
|
33
|
+
reused: boolean
|
|
34
|
+
close: () => Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function tokenPath(runtimeDir: string): string {
|
|
38
|
+
return join(runtimeDir, 'helper.token')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sessionSocketPath(runtimeDir: string): string {
|
|
42
|
+
if (platform() === 'win32')
|
|
43
|
+
return `\\\\.\\pipe\\uhrp-${Buffer.from(runtimeDir).toString('hex').slice(0, 40)}`
|
|
44
|
+
return join(runtimeDir, 'helper.sock')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Connects to the helper already serving this runtime directory, or starts a
|
|
49
|
+
* new one. Reuse matters for multi-instance dev servers: the second instance
|
|
50
|
+
* attaches to the running Caddy instead of asking for a password again.
|
|
51
|
+
*/
|
|
52
|
+
export async function openHelperSession(scope: SessionScope): Promise<OpenedSession> {
|
|
53
|
+
const socketPath = sessionSocketPath(scope.runtimeDir)
|
|
54
|
+
|
|
55
|
+
if (existsSync(socketPath) || platform() === 'win32') {
|
|
56
|
+
const token = await readToken(scope.runtimeDir)
|
|
57
|
+
if (token) {
|
|
58
|
+
const reused = await connectExistingHelper(socketPath, token)
|
|
59
|
+
if (reused) {
|
|
60
|
+
return {
|
|
61
|
+
client: reused,
|
|
62
|
+
reused: true,
|
|
63
|
+
close: async () => {
|
|
64
|
+
await reused.close()
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const token = createHelperToken()
|
|
72
|
+
const started = await startPrivilegedHelper({
|
|
73
|
+
socketPath,
|
|
74
|
+
token,
|
|
75
|
+
helperEntry: scope.helperEntry,
|
|
76
|
+
helperArgs: scope.helperArgs,
|
|
77
|
+
sudoCommand: scope.sudoCommand,
|
|
78
|
+
skipAuth: scope.skipAuth,
|
|
79
|
+
direct: scope.direct,
|
|
80
|
+
env: scope.env,
|
|
81
|
+
caddyStoragePath: scope.caddyStoragePath,
|
|
82
|
+
caddyBin: scope.caddyBin,
|
|
83
|
+
caddyConfigPath: scope.caddyConfigPath,
|
|
84
|
+
log: scope.log,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
await writeToken(scope.runtimeDir, token)
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
client: started.client,
|
|
91
|
+
reused: false,
|
|
92
|
+
close: async () => {
|
|
93
|
+
await started.close()
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readToken(runtimeDir: string): Promise<string | undefined> {
|
|
99
|
+
try {
|
|
100
|
+
const content = await readFile(tokenPath(runtimeDir), 'utf8')
|
|
101
|
+
const token = content.trim()
|
|
102
|
+
return token === '' ? undefined : token
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function writeToken(runtimeDir: string, token: string): Promise<void> {
|
|
110
|
+
try {
|
|
111
|
+
await writeFile(tokenPath(runtimeDir), token, { mode: 0o600 })
|
|
112
|
+
}
|
|
113
|
+
catch {}
|
|
114
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isIP } from 'node:net'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates values that cross the socket. Everything reaching the privileged
|
|
5
|
+
* helper is untrusted, and a hosts line in particular is a plain-text file: an
|
|
6
|
+
* unvalidated hostname containing a newline could inject entries this plugin
|
|
7
|
+
* does not own and therefore never cleans up.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Ports the plugin may free. Wide enough for the two the proxy binds. */
|
|
11
|
+
export const ALLOWED_FREE_PORTS = new Set([80, 443])
|
|
12
|
+
|
|
13
|
+
const HOSTNAME_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i
|
|
14
|
+
const IPV6_IN_HOSTS = /^[0-9a-f:.]+$/i
|
|
15
|
+
|
|
16
|
+
export function isSafeHost(host: unknown): host is string {
|
|
17
|
+
if (typeof host !== 'string' || host.length === 0 || host.length > 253)
|
|
18
|
+
return false
|
|
19
|
+
// A hosts entry is whitespace separated, so any whitespace or control
|
|
20
|
+
// character would split the line or start a new one.
|
|
21
|
+
if (/[\s#\0]/.test(host))
|
|
22
|
+
return false
|
|
23
|
+
return HOSTNAME_PATTERN.test(host)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isSafeIp(ip: unknown): ip is string {
|
|
27
|
+
if (typeof ip !== 'string')
|
|
28
|
+
return false
|
|
29
|
+
if (isIP(ip) !== 0)
|
|
30
|
+
return true
|
|
31
|
+
// `::1` and friends are caught by isIP; this keeps the check explicit for
|
|
32
|
+
// hosts files that spell IPv6 without brackets.
|
|
33
|
+
return IPV6_IN_HOSTS.test(ip)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isAllowedFreePort(port: unknown): port is number {
|
|
37
|
+
return typeof port === 'number' && Number.isInteger(port) && ALLOWED_FREE_PORTS.has(port)
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Windows has no `sudo`: elevation goes through UAC, and the elevated process
|
|
7
|
+
* is a new process that cannot inherit the parent's stdio. The helper talks
|
|
8
|
+
* over a named pipe, so it only needs to be started detached.
|
|
9
|
+
*
|
|
10
|
+
* `Start-Process -Verb RunAs` is the documented way to trigger the UAC prompt
|
|
11
|
+
* without a helper binary or a dependency on `sudo-prompt`.
|
|
12
|
+
*/
|
|
13
|
+
export interface WindowsElevationOptions {
|
|
14
|
+
/** Executable to run elevated; normally the node runtime. */
|
|
15
|
+
file: string
|
|
16
|
+
/** Arguments for that executable. */
|
|
17
|
+
args: string[]
|
|
18
|
+
/** Directory to start in; defaults to the parent's. */
|
|
19
|
+
cwd?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Quotes an argument for PowerShell's single-quoted string syntax. */
|
|
23
|
+
export function quotePowerShell(value: string): string {
|
|
24
|
+
return `'${value.replace(/'/g, '\'\'')}'`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds the PowerShell command that starts `file` elevated. Exported so the
|
|
29
|
+
* escaping can be unit-tested without touching a real UAC prompt.
|
|
30
|
+
*/
|
|
31
|
+
export function buildElevationCommand(options: WindowsElevationOptions): string {
|
|
32
|
+
const argumentList = options.args.map(quotePowerShell).join(',')
|
|
33
|
+
const parts = [
|
|
34
|
+
'Start-Process',
|
|
35
|
+
'-FilePath',
|
|
36
|
+
quotePowerShell(options.file),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
if (argumentList)
|
|
40
|
+
parts.push('-ArgumentList', argumentList)
|
|
41
|
+
|
|
42
|
+
if (options.cwd)
|
|
43
|
+
parts.push('-WorkingDirectory', quotePowerShell(options.cwd))
|
|
44
|
+
|
|
45
|
+
parts.push('-Verb', 'RunAs')
|
|
46
|
+
parts.push('-WindowStyle', 'Hidden')
|
|
47
|
+
|
|
48
|
+
return parts.join(' ')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Starts the helper elevated on Windows. Resolves once PowerShell has accepted
|
|
53
|
+
* the request; the helper's own readiness is observed through the named pipe.
|
|
54
|
+
*/
|
|
55
|
+
export function spawnElevatedOnWindows(options: WindowsElevationOptions): Promise<ChildProcess> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const script = buildElevationCommand(options)
|
|
58
|
+
const child = spawn(
|
|
59
|
+
'powershell.exe',
|
|
60
|
+
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
61
|
+
{ stdio: 'ignore', windowsHide: true },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// PowerShell exits once UAC is answered; a non-zero code means the user
|
|
65
|
+
// dismissed the prompt or elevation was denied.
|
|
66
|
+
child.once('error', reject)
|
|
67
|
+
child.once('exit', (code) => {
|
|
68
|
+
if (code === 0)
|
|
69
|
+
return resolve(child)
|
|
70
|
+
reject(new Error(
|
|
71
|
+
`administrator permission was not granted (PowerShell exited with ${code}). `
|
|
72
|
+
+ 'Accept the UAC prompt to let the plugin edit the hosts file and bind ports 80/443.',
|
|
73
|
+
))
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isWindowsElevationAvailable(): boolean {
|
|
79
|
+
return process.platform === 'win32'
|
|
80
|
+
}
|