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,142 @@
1
+ import { isIP } from 'node:net'
2
+
3
+ /**
4
+ * `/etc/hosts` edits are modelled as pure string transformations so the helper
5
+ * can be tested without touching the real file.
6
+ *
7
+ * `/etc/hosts` is user data: a line may map several names at once
8
+ * (`127.0.0.1 a.test b.test`). Editing one name must never drop the others, and
9
+ * entries this plugin did not write must survive every operation.
10
+ */
11
+
12
+ export const HOSTS_MARKER = '# uhrp'
13
+ export const HOSTS_MARKER_PATTERN = /#\s*uhrp\b/
14
+
15
+ export interface HostsEdit {
16
+ content: string
17
+ changed: boolean
18
+ }
19
+
20
+ interface ParsedLine {
21
+ ip: string
22
+ hosts: string[]
23
+ }
24
+
25
+ function detectEol(content: string): string {
26
+ return content.includes('\r\n') ? '\r\n' : '\n'
27
+ }
28
+
29
+ function toBodyLines(content: string): string[] {
30
+ const lines = content.split(/\r?\n/)
31
+ if (lines.length > 0 && lines[lines.length - 1] === '')
32
+ lines.pop()
33
+ return lines
34
+ }
35
+
36
+ function parseLine(line: string): ParsedLine | undefined {
37
+ const withoutComment = line.replace(/#.*/, '').trim()
38
+ if (!withoutComment)
39
+ return undefined
40
+ const [ip, ...hosts] = withoutComment.split(/\s+/)
41
+ if (!ip || hosts.length === 0)
42
+ return undefined
43
+ return { ip, hosts }
44
+ }
45
+
46
+ function isOwned(line: string): boolean {
47
+ return HOSTS_MARKER_PATTERN.test(line)
48
+ }
49
+
50
+ function mentionsHost(entry: ParsedLine, host: string): boolean {
51
+ return entry.hosts.some(candidate => candidate.toLowerCase() === host.toLowerCase())
52
+ }
53
+
54
+ function sameIpFamily(left: string, right: string): boolean {
55
+ const leftVersion = isIP(left)
56
+ return leftVersion !== 0 && leftVersion === isIP(right)
57
+ }
58
+
59
+ function rebuild(lines: string[], original: string, eol: string): HostsEdit {
60
+ const content = lines.length > 0 ? lines.join(eol) + eol : ''
61
+ return { content, changed: content !== original }
62
+ }
63
+
64
+ /**
65
+ * Drops `host` from its mapping and appends a marked entry for it.
66
+ *
67
+ * A user-written line that also maps other names is rewritten without `host`
68
+ * instead of being deleted, so `127.0.0.1 a.test b.test` keeps `b.test`.
69
+ */
70
+ export function addHost(content: string, ip: string, host: string): HostsEdit {
71
+ const eol = detectEol(content)
72
+ const lines: string[] = []
73
+
74
+ for (const line of toBodyLines(content)) {
75
+ const entry = parseLine(line)
76
+ if (!entry || !mentionsHost(entry, host) || !sameIpFamily(entry.ip, ip)) {
77
+ lines.push(line)
78
+ continue
79
+ }
80
+
81
+ const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase())
82
+ if (remaining.length === 0)
83
+ continue
84
+
85
+ // Preserve the original comment when one exists so a user's annotation is
86
+ // not silently dropped by our edit.
87
+ const comment = line.match(/#.*/)?.[0] ?? ''
88
+ const rebuilt = `${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}`
89
+ lines.push(rebuilt)
90
+ }
91
+
92
+ lines.push(`${ip} ${host} ${HOSTS_MARKER}`)
93
+ return rebuild(lines, content, eol)
94
+ }
95
+
96
+ /**
97
+ * Removes `host` from the mapping to `ip`. Only lines this plugin wrote are
98
+ * removed outright; a user's line keeps its other names.
99
+ */
100
+ export function removeHost(content: string, ip: string, host: string): HostsEdit {
101
+ const eol = detectEol(content)
102
+ const lines: string[] = []
103
+
104
+ for (const line of toBodyLines(content)) {
105
+ const entry = parseLine(line)
106
+ if (!entry || entry.ip !== ip || !mentionsHost(entry, host)) {
107
+ lines.push(line)
108
+ continue
109
+ }
110
+
111
+ const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase())
112
+ if (remaining.length === 0)
113
+ continue
114
+
115
+ const comment = line.match(/#.*/)?.[0] ?? ''
116
+ lines.push(`${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}`)
117
+ }
118
+
119
+ return rebuild(lines, content, eol)
120
+ }
121
+
122
+ /** Drops every entry this plugin wrote, used to clean up after a crashed session. */
123
+ export function removeMarkedHosts(content: string): HostsEdit {
124
+ const eol = detectEol(content)
125
+ const lines = toBodyLines(content).filter(line => !isOwned(line))
126
+ return rebuild(lines, content, eol)
127
+ }
128
+
129
+ /** Hosts entries this plugin owns, without the marker. */
130
+ export function listMarkedHosts(content: string): Array<{ ip: string, host: string }> {
131
+ const result: Array<{ ip: string, host: string }> = []
132
+ for (const line of toBodyLines(content)) {
133
+ if (!isOwned(line))
134
+ continue
135
+ const entry = parseLine(line)
136
+ if (!entry)
137
+ continue
138
+ for (const host of entry.hosts)
139
+ result.push({ ip: entry.ip, host })
140
+ }
141
+ return result
142
+ }
@@ -0,0 +1,193 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { PrivilegedClient } from './client'
3
+ import { spawn, spawnSync } from 'node:child_process'
4
+ import { randomBytes } from 'node:crypto'
5
+ import { platform } from 'node:os'
6
+ import process from 'node:process'
7
+ import { collectForwardedEnv, connectClient } from './client'
8
+ import { spawnElevatedOnWindows } from './windows-elevation'
9
+
10
+ export function createHelperToken(): string {
11
+ return randomBytes(24).toString('hex')
12
+ }
13
+
14
+ export interface LaunchOptions {
15
+ socketPath: string
16
+ token: string
17
+ /** Executable started as root (node, or tsx when running from source). */
18
+ helperEntry: string
19
+ /** Arguments placed before the helper's own `--socket/--token` flags. */
20
+ helperArgs?: string[]
21
+ sudoCommand?: string
22
+ /** Skips the interactive credential check; used by tests and Windows. */
23
+ skipAuth?: boolean
24
+ /**
25
+ * Starts the helper as the current user instead of going through sudo.
26
+ * Used by tests that need the helper protocol but not real privileges.
27
+ */
28
+ direct?: boolean
29
+ env?: NodeJS.ProcessEnv
30
+ log?: (message: string) => void
31
+ /** How long to wait for the helper socket to accept the connection. */
32
+ connectTimeoutMs?: number
33
+ /**
34
+ * Caddy's storage directory. Caddy runs as root in the helper, so the helper
35
+ * hands this tree back to the developer when it stops Caddy.
36
+ */
37
+ caddyStoragePath?: string
38
+ /** Caddy binary the helper is allowed to run, fixed for the whole session. */
39
+ caddyBin: string
40
+ /** Caddyfile the helper is allowed to load. */
41
+ caddyConfigPath: string
42
+ }
43
+
44
+ /**
45
+ * Asks for the administrator password once, up front, on the dev terminal.
46
+ *
47
+ * The credential cache is keyed by terminal, so a later `sudo -n` only finds
48
+ * this entry when the child stays in the same session. That is why the helper
49
+ * is spawned without `detached`.
50
+ */
51
+ export function authenticateSudo(sudoCommand = 'sudo'): { ok: boolean, message?: string } {
52
+ if (platform() === 'win32')
53
+ return { ok: true }
54
+
55
+ if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0)
56
+ return { ok: true }
57
+
58
+ if (spawnSync(sudoCommand, ['-v'], { stdio: 'inherit' }).status === 0)
59
+ return { ok: true }
60
+
61
+ return {
62
+ ok: false,
63
+ message: 'administrator permission is required to edit the hosts file and bind ports 80/443',
64
+ }
65
+ }
66
+
67
+ export interface PrivilegedHelperHandle {
68
+ client: PrivilegedClient
69
+ close: () => Promise<void>
70
+ }
71
+
72
+ /**
73
+ * Starts the privileged helper and connects to it.
74
+ *
75
+ * Only this child runs as root: the Node process that owns the dev server,
76
+ * its caches and `node_modules` keeps the developer's identity. The user's
77
+ * proxy/HOME settings reach Caddy through the helper's environment instead of
78
+ * `sudo -E`.
79
+ */
80
+ export async function startPrivilegedHelper(options: LaunchOptions): Promise<PrivilegedHelperHandle> {
81
+ const {
82
+ socketPath,
83
+ token,
84
+ helperEntry,
85
+ helperArgs = [],
86
+ sudoCommand = 'sudo',
87
+ skipAuth = false,
88
+ direct = false,
89
+ env = process.env,
90
+ log,
91
+ connectTimeoutMs = 120_000,
92
+ } = options
93
+
94
+ if (!skipAuth && !direct) {
95
+ const auth = authenticateSudo(sudoCommand)
96
+ if (!auth.ok)
97
+ throw new Error(auth.message)
98
+ }
99
+
100
+ const forwarded = collectForwardedEnv(env)
101
+ const forwardedArgs = Object.entries(forwarded).map(([key, value]) => `${key}=${value}`)
102
+ // `helperEntry` is the runtime and `helperArgs` the script it must run, so
103
+ // the helper's own flags always come last. The owner ids travel with them
104
+ // because the helper only learns who its client is at handshake time.
105
+ const childArgv = [
106
+ ...helperArgs,
107
+ '--socket',
108
+ socketPath,
109
+ '--token',
110
+ token,
111
+ ...(typeof process.getuid === 'function' ? ['--owner-uid', String(process.getuid())] : []),
112
+ ...(typeof process.getgid === 'function' ? ['--owner-gid', String(process.getgid())] : []),
113
+ ...(options.caddyStoragePath ? ['--caddy-storage', options.caddyStoragePath] : []),
114
+ '--caddy',
115
+ options.caddyBin,
116
+ '--caddy-config',
117
+ options.caddyConfigPath,
118
+ ]
119
+
120
+ let child: ReturnType<typeof spawn> | undefined
121
+
122
+ if (!direct && platform() === 'win32') {
123
+ // UAC: the elevated helper is a separate process and cannot inherit stdio,
124
+ // so it reports through the named pipe instead. The user's environment is
125
+ // recovered by the helper from its own process env, which Windows keeps.
126
+ await spawnElevatedOnWindows({ file: helperEntry, args: childArgv, cwd: process.cwd() })
127
+ }
128
+ else {
129
+ const command = direct ? helperEntry : sudoCommand
130
+ const spawnArgs = direct
131
+ ? childArgv
132
+ : ['-n', 'env', ...forwardedArgs, helperEntry, ...childArgv]
133
+
134
+ // No `detached`: sudo stores its credential per session, and `setsid()`
135
+ // would make the helper look like a different login, so `sudo -n` could not
136
+ // reuse the password entered a moment earlier. The stdio pipes keep the
137
+ // helper reachable from this process.
138
+ child = spawn(command, spawnArgs, {
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ })
141
+
142
+ child.stdout?.on('data', (data: Buffer) => log?.(data.toString().trimEnd()))
143
+ child.stderr?.on('data', (data: Buffer) => log?.(data.toString().trimEnd()))
144
+
145
+ const exited = new Promise<number | null>(resolve => child!.once('exit', code => resolve(code)))
146
+
147
+ // Refused credentials make the helper exit immediately; report that instead
148
+ // of waiting for the full connection timeout.
149
+ const earlyExit = await Promise.race([
150
+ exited,
151
+ new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 500)),
152
+ ])
153
+
154
+ if (earlyExit !== undefined && earlyExit !== 0) {
155
+ throw new Error(
156
+ `privileged helper exited with code ${earlyExit}. `
157
+ + 'Administrator permission was refused, or sudo is not available.',
158
+ )
159
+ }
160
+ }
161
+
162
+ try {
163
+ const client = await connectClient({ socketPath, token, timeoutMs: connectTimeoutMs })
164
+ return {
165
+ client,
166
+ async close() {
167
+ await client.close().catch(() => {})
168
+ child?.unref()
169
+ },
170
+ }
171
+ }
172
+ catch (error) {
173
+ try {
174
+ child?.kill()
175
+ }
176
+ catch {}
177
+ throw error
178
+ }
179
+ }
180
+
181
+ /** Reuses a helper that is already running for this runtime directory. */
182
+ export async function connectExistingHelper(
183
+ socketPath: string,
184
+ token: string,
185
+ timeoutMs = 1500,
186
+ ): Promise<PrivilegedClient | undefined> {
187
+ try {
188
+ return await connectClient({ socketPath, token, timeoutMs })
189
+ }
190
+ catch {
191
+ return undefined
192
+ }
193
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Helpers for freeing a listening TCP port without shelling out to `npx`.
3
+ * `parseListeningPids` keeps the decision testable: it only turns command
4
+ * output into pids, never spawns anything.
5
+ */
6
+
7
+ /** Parses `lsof -ti:<port> -sTCP:LISTEN` output (one pid per line). */
8
+ export function parseListeningPids(output: string): number[] {
9
+ const pids = new Set<number>()
10
+ for (const line of output.split(/\r?\n/)) {
11
+ const trimmed = line.trim()
12
+ if (!trimmed)
13
+ continue
14
+ if (!/^\d+$/.test(trimmed))
15
+ continue
16
+ const pid = Number(trimmed)
17
+ if (Number.isSafeInteger(pid) && pid > 0)
18
+ pids.add(pid)
19
+ }
20
+ return [...pids]
21
+ }
22
+
23
+ /** Parses `netstat -ano` output for the owning pids of a listening port on Windows. */
24
+ export function parseWindowsListeningPids(output: string, port: number): number[] {
25
+ const pids = new Set<number>()
26
+ const suffix = `:${port}`
27
+
28
+ for (const line of output.split(/\r?\n/)) {
29
+ const columns = line.trim().split(/\s+/)
30
+ if (columns.length < 5)
31
+ continue
32
+ const protocol = columns[0].toUpperCase()
33
+ if (protocol !== 'TCP')
34
+ continue
35
+ if (!columns[1].endsWith(suffix))
36
+ continue
37
+ if (columns[3].toUpperCase() !== 'LISTENING')
38
+ continue
39
+ const pid = Number(columns[4])
40
+ if (Number.isSafeInteger(pid) && pid > 0)
41
+ pids.add(pid)
42
+ }
43
+
44
+ return [...pids]
45
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Wire protocol between the unprivileged plugin process and the privileged
3
+ * helper. Newline-delimited JSON keeps the helper dependency-free.
4
+ *
5
+ * The helper never executes a binary or uses a path supplied by the client:
6
+ * both are fixed when the helper starts (`--caddy` and `--caddy-config`), so a
7
+ * client that reaches the socket cannot turn the helper into a root shell.
8
+ */
9
+
10
+ export type RpcId = number
11
+
12
+ export interface RpcErrorPayload {
13
+ message: string
14
+ code?: string
15
+ }
16
+
17
+ export type HelperRequest =
18
+ | { id: RpcId, method: 'ping' }
19
+ | { id: RpcId, method: 'hosts.add', params: { ip: string, host: string } }
20
+ | { id: RpcId, method: 'hosts.remove', params: { ip: string, host: string } }
21
+ | { id: RpcId, method: 'port.free', params: { port: number } }
22
+ | { id: RpcId, method: 'caddy.start' }
23
+ | { id: RpcId, method: 'caddy.stop' }
24
+ | { id: RpcId, method: 'ca.trust' }
25
+
26
+ /** Request method names, used by the client's typed `call` overloads. */
27
+ export type HelperMethod = HelperRequest['method']
28
+
29
+ export type HelperResponse =
30
+ | { id: RpcId, ok: true, result?: unknown }
31
+ | { id: RpcId, ok: false, error: RpcErrorPayload }
32
+
33
+ export type HelperEvent =
34
+ | { type: 'event', kind: 'caddy.stdout', data: string }
35
+ | { type: 'event', kind: 'caddy.stderr', data: string }
36
+ | { type: 'event', kind: 'caddy.exit', code: number | null, signal: string | null }
37
+
38
+ export type HelperMessage = HelperResponse | HelperEvent
39
+
40
+ export interface ClientHandshake {
41
+ type: 'handshake'
42
+ token: string
43
+ }
44
+
45
+ export interface ServerHandshake {
46
+ type: 'handshake'
47
+ ok: boolean
48
+ pid?: number
49
+ reason?: string
50
+ }
51
+
52
+ export type DecodedMessage =
53
+ | { ok: true, message: HelperMessage }
54
+ | { ok: false, error: string }
55
+
56
+ export function serializeMessage(message: unknown): string {
57
+ return `${JSON.stringify(message)}\n`
58
+ }
59
+
60
+ /** Splits a stream into complete lines, keeping the trailing partial line. */
61
+ export function splitLines(buffer: string, chunk: string): { lines: string[], rest: string } {
62
+ const parts = (buffer + chunk).split('\n')
63
+ const rest = parts.pop() ?? ''
64
+ return { lines: parts.filter(line => line.trim() !== ''), rest }
65
+ }
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === 'object' && value !== null
69
+ }
70
+
71
+ export function decodeMessage(line: string): DecodedMessage {
72
+ let parsed: unknown
73
+ try {
74
+ parsed = JSON.parse(line)
75
+ }
76
+ catch (error) {
77
+ return { ok: false, error: `invalid JSON: ${(error as Error).message}` }
78
+ }
79
+
80
+ if (!isRecord(parsed))
81
+ return { ok: false, error: 'message must be an object' }
82
+
83
+ if (parsed.type === 'event') {
84
+ if (typeof parsed.kind !== 'string')
85
+ return { ok: false, error: 'event.kind must be a string' }
86
+ return { ok: true, message: parsed as unknown as HelperEvent }
87
+ }
88
+
89
+ if (typeof parsed.id !== 'number')
90
+ return { ok: false, error: 'response.id must be a number' }
91
+
92
+ if (parsed.ok === true)
93
+ return { ok: true, message: { id: parsed.id, ok: true, result: parsed.result } }
94
+
95
+ if (parsed.ok === false) {
96
+ const error = isRecord(parsed.error) ? parsed.error : {}
97
+ return {
98
+ ok: true,
99
+ message: {
100
+ id: parsed.id,
101
+ ok: false,
102
+ error: {
103
+ message: typeof error.message === 'string' ? error.message : 'helper request failed',
104
+ code: typeof error.code === 'string' ? error.code : undefined,
105
+ },
106
+ },
107
+ }
108
+ }
109
+
110
+ return { ok: false, error: 'response.ok must be a boolean' }
111
+ }