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.
Files changed (47) hide show
  1. package/README.md +30 -6
  2. package/dist/{chunk-5R33PUJP.js → chunk-7CA3EIIX.js} +681 -223
  3. package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
  4. package/dist/chunk-RKHJN6KY.js +53 -0
  5. package/dist/esbuild.cjs +781 -300
  6. package/dist/esbuild.js +3 -2
  7. package/dist/index.cjs +781 -300
  8. package/dist/index.js +3 -2
  9. package/dist/nuxt.cjs +779 -290
  10. package/dist/nuxt.js +5 -9
  11. package/dist/privileged-helper.cjs +668 -0
  12. package/dist/privileged-helper.d.cts +2 -0
  13. package/dist/privileged-helper.d.ts +2 -0
  14. package/dist/privileged-helper.js +641 -0
  15. package/dist/rollup.cjs +781 -300
  16. package/dist/rollup.js +3 -2
  17. package/dist/rspack.cjs +781 -300
  18. package/dist/rspack.js +3 -2
  19. package/dist/utils.cjs +0 -28
  20. package/dist/utils.d.cts +1 -3
  21. package/dist/utils.d.ts +1 -3
  22. package/dist/utils.js +1 -5
  23. package/dist/vite.cjs +781 -300
  24. package/dist/vite.js +3 -2
  25. package/dist/webpack.cjs +781 -300
  26. package/dist/webpack.js +3 -2
  27. package/package.json +1 -4
  28. package/src/caddy/caddyfile.ts +1 -0
  29. package/src/caddy/index.ts +183 -110
  30. package/src/caddy/privileged.ts +118 -0
  31. package/src/caddy/utils.ts +0 -43
  32. package/src/index.ts +1 -15
  33. package/src/nuxt.ts +1 -6
  34. package/src/privileged/bootstrap.ts +174 -0
  35. package/src/privileged/client.ts +287 -0
  36. package/src/privileged/entry.ts +45 -0
  37. package/src/privileged/hosts-file.ts +142 -0
  38. package/src/privileged/launch.ts +193 -0
  39. package/src/privileged/ports.ts +45 -0
  40. package/src/privileged/protocol.ts +111 -0
  41. package/src/privileged/server.ts +465 -0
  42. package/src/privileged/session.ts +114 -0
  43. package/src/privileged/validation.ts +38 -0
  44. package/src/privileged/windows-elevation.ts +80 -0
  45. package/src/privileged-helper.ts +109 -0
  46. package/src/utils.ts +0 -28
  47. 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
+ }