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.
Files changed (56) hide show
  1. package/README.md +30 -6
  2. package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
  3. package/dist/chunk-RKHJN6KY.js +53 -0
  4. package/dist/{chunk-5R33PUJP.js → chunk-U7KAIPZC.js} +981 -796
  5. package/dist/chunk-WOMR3ZLM.js +17 -0
  6. package/dist/esbuild.cjs +1077 -864
  7. package/dist/esbuild.js +4 -2
  8. package/dist/index.cjs +1077 -864
  9. package/dist/index.js +4 -2
  10. package/dist/nuxt.cjs +1049 -850
  11. package/dist/nuxt.js +6 -9
  12. package/dist/privilege-state.cjs +44 -0
  13. package/dist/privilege-state.d.cts +7 -0
  14. package/dist/privilege-state.d.ts +7 -0
  15. package/dist/privilege-state.js +10 -0
  16. package/dist/privileged-helper.cjs +824 -0
  17. package/dist/privileged-helper.d.cts +2 -0
  18. package/dist/privileged-helper.d.ts +2 -0
  19. package/dist/privileged-helper.js +797 -0
  20. package/dist/rollup.cjs +1077 -864
  21. package/dist/rollup.js +4 -2
  22. package/dist/rspack.cjs +1077 -864
  23. package/dist/rspack.js +4 -2
  24. package/dist/utils.cjs +0 -28
  25. package/dist/utils.d.cts +1 -3
  26. package/dist/utils.d.ts +1 -3
  27. package/dist/utils.js +1 -5
  28. package/dist/vite.cjs +1077 -864
  29. package/dist/vite.js +4 -2
  30. package/dist/webpack.cjs +1077 -864
  31. package/dist/webpack.js +4 -2
  32. package/package.json +1 -4
  33. package/src/caddy/caddyfile.ts +3 -0
  34. package/src/caddy/config.ts +195 -53
  35. package/src/caddy/constants.ts +30 -1
  36. package/src/caddy/index.ts +239 -206
  37. package/src/caddy/privileged.ts +124 -0
  38. package/src/caddy/utils.ts +0 -43
  39. package/src/index.ts +49 -22
  40. package/src/nuxt.ts +1 -6
  41. package/src/privilege-state.ts +23 -0
  42. package/src/privileged/bootstrap.ts +289 -0
  43. package/src/privileged/client.ts +288 -0
  44. package/src/privileged/entry.ts +73 -0
  45. package/src/privileged/hosts-file.ts +142 -0
  46. package/src/privileged/launch.ts +332 -0
  47. package/src/privileged/path-guard.ts +74 -0
  48. package/src/privileged/ports.ts +45 -0
  49. package/src/privileged/protocol.ts +111 -0
  50. package/src/privileged/server.ts +578 -0
  51. package/src/privileged/session.ts +169 -0
  52. package/src/privileged/validation.ts +38 -0
  53. package/src/privileged/windows-elevation.ts +80 -0
  54. package/src/privileged-helper.ts +123 -0
  55. package/src/utils.ts +0 -28
  56. package/src/host/index.ts +0 -52
@@ -0,0 +1,288 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { Socket } from 'node:net'
3
+ import type {
4
+ ClientHandshake,
5
+ HelperEvent,
6
+ HelperMessage,
7
+ HelperRequest,
8
+ RpcId,
9
+ ServerHandshake,
10
+ } from './protocol'
11
+ import { connect } from 'node:net'
12
+ import process from 'node:process'
13
+ import { decodeMessage, serializeMessage, splitLines } from './protocol'
14
+
15
+ /**
16
+ * Environment variables the privileged helper needs so Caddy behaves like the
17
+ * user's own process (PKI location, proxy settings) without `sudo -E`.
18
+ */
19
+ export const FORWARDED_ENV_KEYS = [
20
+ 'HOME',
21
+ 'USER',
22
+ 'LOGNAME',
23
+ 'TMPDIR',
24
+ 'HTTP_PROXY',
25
+ 'HTTPS_PROXY',
26
+ 'http_proxy',
27
+ 'https_proxy',
28
+ 'NO_PROXY',
29
+ 'no_proxy',
30
+ ] as const
31
+
32
+ export function collectForwardedEnv(source: NodeJS.ProcessEnv = process.env): Record<string, string> {
33
+ const result: Record<string, string> = {}
34
+ for (const key of FORWARDED_ENV_KEYS) {
35
+ const value = source[key]
36
+ if (typeof value === 'string' && value !== '')
37
+ result[key] = value
38
+ }
39
+ return result
40
+ }
41
+
42
+ export interface HelperResults {
43
+ 'ping': { pid: number }
44
+ 'hosts.add': void
45
+ 'hosts.remove': void
46
+ 'port.free': { killed: number[] }
47
+ /** `started: false` means Caddy was already running and was reused. */
48
+ 'caddy.start': { pid?: number, started?: boolean }
49
+ 'caddy.stop': void
50
+ 'ca.trust': { trusted: boolean, output: string }
51
+ }
52
+
53
+ export class HelperConnectionError extends Error {
54
+ constructor(message: string) {
55
+ super(message)
56
+ this.name = 'HelperConnectionError'
57
+ }
58
+ }
59
+
60
+ export interface PrivilegedClient {
61
+ readonly connected: boolean
62
+ call: <M extends keyof HelperResults>(method: M, ...params: HelperParams<M>) => Promise<HelperResults[M]>
63
+ onEvent: (listener: (event: HelperEvent) => void) => () => void
64
+ close: () => Promise<void>
65
+ }
66
+
67
+ type HelperParams<M extends keyof HelperResults>
68
+ = M extends 'hosts.add' | 'hosts.remove' ? [params: { ip: string, host: string }]
69
+ : M extends 'port.free' ? [params: { port: number }]
70
+ : []
71
+
72
+ interface PendingCall {
73
+ resolve: (value: unknown) => void
74
+ reject: (error: Error) => void
75
+ timer: ReturnType<typeof setTimeout>
76
+ }
77
+
78
+ export interface ConnectOptions {
79
+ socketPath: string
80
+ token: string
81
+ /** Total time to wait for the helper to accept the connection. */
82
+ timeoutMs?: number
83
+ retryIntervalMs?: number
84
+ callTimeoutMs?: number
85
+ }
86
+
87
+ function openSocket(socketPath: string): Promise<Socket> {
88
+ return new Promise<Socket>((resolve, reject) => {
89
+ const socket = connect(socketPath)
90
+
91
+ function onConnect(): void {
92
+ socket.removeListener('error', onError)
93
+ resolve(socket)
94
+ }
95
+
96
+ function onError(error: Error): void {
97
+ socket.removeListener('connect', onConnect)
98
+ reject(error)
99
+ }
100
+
101
+ socket.once('error', onError)
102
+ socket.once('connect', onConnect)
103
+ })
104
+ }
105
+
106
+ /**
107
+ * Connects to an already-running helper and performs the token handshake.
108
+ * Retries because the helper may still be starting while sudo asks for a password.
109
+ */
110
+ export async function connectClient(options: ConnectOptions): Promise<PrivilegedClient> {
111
+ const {
112
+ socketPath,
113
+ token,
114
+ timeoutMs = 120_000,
115
+ retryIntervalMs = 100,
116
+ callTimeoutMs = 30_000,
117
+ } = options
118
+
119
+ const deadline = Date.now() + timeoutMs
120
+ let lastError: Error | undefined
121
+
122
+ while (Date.now() < deadline) {
123
+ try {
124
+ return await handshake(socketPath, token, callTimeoutMs)
125
+ }
126
+ catch (error) {
127
+ lastError = error as Error
128
+
129
+ // A permission or protocol error will not resolve by waiting, and
130
+ // retrying hides the real cause behind a silent hang.
131
+ const code = (lastError as NodeJS.ErrnoException).code
132
+ if (code === 'EACCES' || lastError instanceof HelperConnectionError)
133
+ throw lastError
134
+
135
+ await new Promise(resolve => setTimeout(resolve, retryIntervalMs))
136
+ }
137
+ }
138
+
139
+ throw new HelperConnectionError(
140
+ `privileged helper is not reachable at ${socketPath}: ${lastError?.message ?? 'timed out'}`,
141
+ )
142
+ }
143
+
144
+ function handshake(socketPath: string, token: string, callTimeoutMs: number): Promise<PrivilegedClient> {
145
+ return new Promise<PrivilegedClient>((resolve, reject) => {
146
+ void openSocket(socketPath).then((socket) => {
147
+ let buffer = ''
148
+ let settled = false
149
+
150
+ const fail = (error: Error): void => {
151
+ if (settled)
152
+ return
153
+ settled = true
154
+ socket.destroy()
155
+ reject(error)
156
+ }
157
+
158
+ const onData = (chunk: Buffer): void => {
159
+ if (settled)
160
+ return
161
+
162
+ const split = splitLines(buffer, chunk.toString('utf8'))
163
+ buffer = split.rest
164
+ const line = split.lines[0]
165
+ if (line === undefined)
166
+ return
167
+
168
+ let hello: ServerHandshake
169
+ try {
170
+ hello = JSON.parse(line) as ServerHandshake
171
+ }
172
+ catch (error) {
173
+ return fail(new HelperConnectionError(`invalid handshake reply: ${(error as Error).message}`))
174
+ }
175
+
176
+ if (hello.type !== 'handshake')
177
+ return fail(new HelperConnectionError('helper did not answer the handshake'))
178
+
179
+ if (!hello.ok)
180
+ return fail(new HelperConnectionError(hello.reason ?? 'helper rejected the handshake'))
181
+
182
+ settled = true
183
+ socket.removeListener('data', onData)
184
+ socket.removeListener('error', onError)
185
+ const remaining = split.lines.slice(1)
186
+ resolve(createClient(socket, [split.rest, ...remaining].filter(Boolean).join('\n'), callTimeoutMs))
187
+ }
188
+
189
+ function onError(error: Error): void {
190
+ fail(error)
191
+ }
192
+
193
+ socket.on('data', onData)
194
+ socket.on('error', onError)
195
+
196
+ const greeting: ClientHandshake = { type: 'handshake', token }
197
+ socket.write(serializeMessage(greeting))
198
+ }, reject)
199
+ })
200
+ }
201
+
202
+ function createClient(socket: Socket, initialBuffer: string, callTimeoutMs: number): PrivilegedClient {
203
+ let buffer = initialBuffer
204
+ let connected = true
205
+ let nextId: RpcId = 1
206
+
207
+ const pending = new Map<RpcId, PendingCall>()
208
+ const listeners = new Set<(event: HelperEvent) => void>()
209
+
210
+ socket.on('data', (chunk: Buffer) => {
211
+ const split = splitLines(buffer, chunk.toString('utf8'))
212
+ buffer = split.rest
213
+
214
+ for (const line of split.lines) {
215
+ const decoded = decodeMessage(line)
216
+ if (!decoded.ok)
217
+ continue
218
+
219
+ const message: HelperMessage = decoded.message
220
+ if (!('id' in message)) {
221
+ for (const listener of listeners)
222
+ listener(message)
223
+ continue
224
+ }
225
+
226
+ const entry = pending.get(message.id)
227
+ if (!entry)
228
+ continue
229
+
230
+ pending.delete(message.id)
231
+ clearTimeout(entry.timer)
232
+
233
+ if (message.ok)
234
+ entry.resolve(message.result)
235
+ else
236
+ entry.reject(new Error(message.error.message))
237
+ }
238
+ })
239
+
240
+ const disconnect = (): void => {
241
+ if (!connected)
242
+ return
243
+ connected = false
244
+ for (const [id, entry] of pending) {
245
+ clearTimeout(entry.timer)
246
+ pending.delete(id)
247
+ entry.reject(new HelperConnectionError('privileged helper connection closed'))
248
+ }
249
+ }
250
+
251
+ socket.on('close', disconnect)
252
+ socket.on('error', disconnect)
253
+
254
+ function call(method: keyof HelperResults, params?: unknown): Promise<unknown> {
255
+ if (!connected)
256
+ return Promise.reject(new HelperConnectionError('privileged helper is not connected'))
257
+
258
+ const id = nextId++
259
+ const request = { id, method, params } as unknown as HelperRequest
260
+
261
+ return new Promise<unknown>((resolve, reject) => {
262
+ const timer = setTimeout(() => {
263
+ pending.delete(id)
264
+ reject(new HelperConnectionError(`privileged helper did not answer "${method}" in time`))
265
+ }, callTimeoutMs)
266
+
267
+ pending.set(id, { resolve, reject, timer })
268
+ socket.write(serializeMessage(request))
269
+ })
270
+ }
271
+
272
+ return {
273
+ get connected() {
274
+ return connected
275
+ },
276
+ call: call as PrivilegedClient['call'],
277
+ onEvent(listener) {
278
+ listeners.add(listener)
279
+ return () => {
280
+ listeners.delete(listener)
281
+ }
282
+ },
283
+ async close() {
284
+ disconnect()
285
+ socket.destroy()
286
+ },
287
+ }
288
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ export interface HelperEntry {
6
+ /**
7
+ * Executable started through sudo. This is the node runtime, so the caller
8
+ * only has to append `args` and the helper's own flags.
9
+ */
10
+ entry: string
11
+ /** Script (and loader) arguments that follow the runtime. */
12
+ args: string[]
13
+ }
14
+
15
+ export interface ResolveHelperEntryOptions {
16
+ /** Directory of the running bundle: `dist` in published builds, `src` from source. */
17
+ moduleDir: string
18
+ /** Node executable used to run a compiled helper. */
19
+ nodeBin?: string
20
+ exists?: (path: string) => boolean
21
+ }
22
+
23
+ /**
24
+ * Locates the helper entry next to the running module.
25
+ *
26
+ * Published builds ship a compiled `privileged-helper.js` and start it with
27
+ * node; running from the repository uses `tsx` so the TypeScript source works
28
+ * without a build step.
29
+ *
30
+ * The running module sits in a subdirectory (`src/caddy`, `src/privileged`),
31
+ * while the helper lives one level up in `src`, so both lookups walk up instead
32
+ * of assuming a fixed depth.
33
+ */
34
+ export function resolveHelperEntry(options: ResolveHelperEntryOptions): HelperEntry {
35
+ const { moduleDir, nodeBin = process.execPath, exists = existsSync } = options
36
+
37
+ for (const name of ['privileged-helper.js', 'privileged-helper.cjs', 'privileged-helper.mjs']) {
38
+ const candidate = findUp(moduleDir, name, exists)
39
+ if (candidate)
40
+ return { entry: nodeBin, args: [candidate] }
41
+ }
42
+
43
+ const source = findUp(moduleDir, 'privileged-helper.ts', exists)
44
+ const tsxCli = findUp(moduleDir, 'node_modules/tsx/dist/cli.mjs', exists)
45
+ if (source && tsxCli)
46
+ return { entry: nodeBin, args: [tsxCli, source] }
47
+
48
+ throw new Error(`privileged helper entry not found in ${moduleDir}. Build the package first.`)
49
+ }
50
+
51
+ /**
52
+ * Walks up from `from` looking for a relative path.
53
+ *
54
+ * Stops at the filesystem root so a helper outside the project is never picked
55
+ * up by accident.
56
+ */
57
+ function findUp(
58
+ from: string,
59
+ relative: string,
60
+ exists: (path: string) => boolean,
61
+ ): string | undefined {
62
+ let current = from
63
+ while (true) {
64
+ const candidate = join(current, relative)
65
+ if (exists(candidate))
66
+ return candidate
67
+
68
+ const parent = dirname(current)
69
+ if (parent === current)
70
+ return undefined
71
+ current = parent
72
+ }
73
+ }
@@ -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
+ }