unplugin-https-reverse-proxy 4.0.3 → 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 (62) hide show
  1. package/README.md +30 -6
  2. package/dist/{chunk-RSBXFJ3Z.js → chunk-C5A7XLKL.js} +9 -2
  3. package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
  4. package/dist/chunk-RKHJN6KY.js +53 -0
  5. package/dist/{chunk-MD5CVZP7.js → chunk-U7KAIPZC.js} +982 -797
  6. package/dist/chunk-WOMR3ZLM.js +17 -0
  7. package/dist/esbuild.cjs +1086 -867
  8. package/dist/esbuild.js +5 -3
  9. package/dist/hmr-sse-bridge.cjs +12 -4
  10. package/dist/hmr-sse-bridge.d.cts +4 -3
  11. package/dist/hmr-sse-bridge.d.ts +4 -3
  12. package/dist/hmr-sse-bridge.js +5 -3
  13. package/dist/index.cjs +1086 -867
  14. package/dist/index.js +5 -3
  15. package/dist/nuxt.cjs +1057 -852
  16. package/dist/nuxt.js +6 -9
  17. package/dist/privilege-state.cjs +44 -0
  18. package/dist/privilege-state.d.cts +7 -0
  19. package/dist/privilege-state.d.ts +7 -0
  20. package/dist/privilege-state.js +10 -0
  21. package/dist/privileged-helper.cjs +824 -0
  22. package/dist/privileged-helper.d.cts +2 -0
  23. package/dist/privileged-helper.d.ts +2 -0
  24. package/dist/privileged-helper.js +797 -0
  25. package/dist/rollup.cjs +1086 -867
  26. package/dist/rollup.js +5 -3
  27. package/dist/rspack.cjs +1086 -867
  28. package/dist/rspack.js +5 -3
  29. package/dist/utils.cjs +0 -28
  30. package/dist/utils.d.cts +1 -3
  31. package/dist/utils.d.ts +1 -3
  32. package/dist/utils.js +1 -5
  33. package/dist/vite.cjs +1086 -867
  34. package/dist/vite.js +5 -3
  35. package/dist/webpack.cjs +1086 -867
  36. package/dist/webpack.js +5 -3
  37. package/package.json +1 -4
  38. package/src/caddy/caddyfile.ts +3 -0
  39. package/src/caddy/config.ts +195 -53
  40. package/src/caddy/constants.ts +30 -1
  41. package/src/caddy/index.ts +239 -206
  42. package/src/caddy/privileged.ts +124 -0
  43. package/src/caddy/utils.ts +0 -43
  44. package/src/hmr-sse-bridge.ts +14 -3
  45. package/src/index.ts +52 -25
  46. package/src/nuxt.ts +1 -6
  47. package/src/privilege-state.ts +23 -0
  48. package/src/privileged/bootstrap.ts +289 -0
  49. package/src/privileged/client.ts +288 -0
  50. package/src/privileged/entry.ts +73 -0
  51. package/src/privileged/hosts-file.ts +142 -0
  52. package/src/privileged/launch.ts +332 -0
  53. package/src/privileged/path-guard.ts +74 -0
  54. package/src/privileged/ports.ts +45 -0
  55. package/src/privileged/protocol.ts +111 -0
  56. package/src/privileged/server.ts +578 -0
  57. package/src/privileged/session.ts +169 -0
  58. package/src/privileged/validation.ts +38 -0
  59. package/src/privileged/windows-elevation.ts +80 -0
  60. package/src/privileged-helper.ts +123 -0
  61. package/src/utils.ts +0 -28
  62. package/src/host/index.ts +0 -52
@@ -0,0 +1,289 @@
1
+ import type { Socket } from 'node:net'
2
+ import type { ClientHandshake } from './protocol'
3
+ import type { HelperDeps, HelperRuntime } from './server'
4
+ import { Buffer } from 'node:buffer'
5
+ import { chmodSync, chownSync, existsSync, readFileSync, rmSync } from 'node:fs'
6
+ import { createServer } from 'node:net'
7
+ import { dirname, join } from 'node:path'
8
+ import process from 'node:process'
9
+ import { checkPrivilegedPath, describeRejection } from './path-guard'
10
+ import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server'
11
+
12
+ export interface HelperBootstrapOptions {
13
+ socketPath: string
14
+ token: string
15
+ hostsPath?: string
16
+ /** Caddy binary; fixed here so the client never names an executable. */
17
+ caddyBin: string
18
+ /** Caddyfile to load; fixed for the same reason. */
19
+ caddyConfigPath: string
20
+ /** Admin API socket; used by `ca.trust` instead of the TCP default. */
21
+ caddyAdminSocket?: string
22
+ caddyCwd?: string
23
+ caddyEnv?: Record<string, string>
24
+ /**
25
+ * Owner of the socket file. The helper runs as root while its client runs as
26
+ * the developer, so the socket must be handed back or the client cannot
27
+ * connect to it.
28
+ */
29
+ ownerUid?: number
30
+ ownerGid?: number
31
+ /** Caddy storage directory, handed back to the owner when Caddy stops. */
32
+ caddyStoragePath?: string
33
+ /**
34
+ * How long the helper lingers with no clients before exiting.
35
+ *
36
+ * Kept short so a helper nobody is using does not stay on the machine as an
37
+ * idle root process. Within the same terminal a restart usually happens
38
+ * sooner than this and still reuses the running helper.
39
+ */
40
+ idleTimeoutMs?: number
41
+ /**
42
+ * Whether the idle timer may end this process.
43
+ *
44
+ * True for the real helper, which owns its process. False when `startHelper`
45
+ * is called in-process (tests): exiting there would take the caller down
46
+ * with it.
47
+ */
48
+ exitOnIdle?: boolean
49
+ deps?: HelperDeps
50
+ log?: (message: string) => void
51
+ }
52
+
53
+ export interface RunningHelper {
54
+ runtime: HelperRuntime
55
+ close: () => Promise<void>
56
+ }
57
+
58
+ const HANDSHAKE_TIMEOUT_MS = 120_000
59
+
60
+ /** Default idle lifetime after the last client leaves. */
61
+ export const DEFAULT_IDLE_TIMEOUT_MS = 30_000
62
+
63
+ /**
64
+ * Runs the privileged side of the plugin: listens on a local socket, accepts
65
+ * only a client presenting the shared token, then serves until that client
66
+ * disconnects. Losing the client is the cleanup trigger, so a crashed or
67
+ * `SIGKILL`ed plugin still leaves hosts and Caddy restored.
68
+ */
69
+ export async function startHelper(options: HelperBootstrapOptions): Promise<RunningHelper> {
70
+ const { socketPath, token } = options
71
+ const log = options.log ?? ((message: string) => process.stdout.write(`[uhrp-helper] ${message}\n`))
72
+
73
+ // The binary and the config travel in as arguments from a process running as
74
+ // the developer. Refuse them when they can be rewritten by someone else:
75
+ // running as root, a swapped binary or Caddyfile is a local privilege gain.
76
+ for (const check of [
77
+ checkPrivilegedPath(options.caddyBin, { label: 'Caddy binary', ownerUid: options.ownerUid }),
78
+ checkPrivilegedPath(options.caddyConfigPath, { label: 'Caddyfile', ownerUid: options.ownerUid }),
79
+ ]) {
80
+ if (check) {
81
+ log(describeRejection(check))
82
+ throw new Error(describeRejection(check))
83
+ }
84
+ }
85
+ const deps = options.deps ?? createNodeHelperDeps({
86
+ hostsPath: options.hostsPath,
87
+ log,
88
+ caddyBin: options.caddyBin,
89
+ caddyConfigPath: options.caddyConfigPath,
90
+ caddyAdminSocket: options.caddyAdminSocket,
91
+ caddyCwd: options.caddyCwd,
92
+ caddyEnv: options.caddyEnv,
93
+ caddyStoragePath: options.caddyStoragePath,
94
+ storageOwner: typeof options.ownerUid === 'number'
95
+ ? { uid: options.ownerUid, gid: options.ownerGid ?? options.ownerUid }
96
+ : undefined,
97
+ })
98
+ const runtime = createHelperRuntime(deps)
99
+
100
+ // A previous session that was killed cannot have cleaned up its entries; do
101
+ // it now, before any client attaches.
102
+ await runtime.sweepStaleEntries().catch(() => {})
103
+
104
+ try {
105
+ rmSync(socketPath, { force: true })
106
+ }
107
+ catch {}
108
+
109
+ const connections = new Set<Socket>()
110
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
111
+ // The real helper is a process of its own and may end itself. When this runs
112
+ // inside a host process (tests), only the socket is released.
113
+ const exitOnIdle = options.exitOnIdle ?? true
114
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
115
+
116
+ const stopIdleTimer = (): void => {
117
+ if (idleTimer) {
118
+ clearTimeout(idleTimer)
119
+ idleTimer = undefined
120
+ }
121
+ }
122
+
123
+ const server = createServer((socket) => {
124
+ let buffer = ''
125
+ let authenticated = false
126
+ stopIdleTimer()
127
+ connections.add(socket)
128
+ socket.on('close', () => {
129
+ connections.delete(socket)
130
+ if (connections.size === 0)
131
+ scheduleIdleExit()
132
+ })
133
+
134
+ const timer = setTimeout(() => {
135
+ if (!authenticated)
136
+ socket.destroy()
137
+ }, HANDSHAKE_TIMEOUT_MS)
138
+ timer.unref?.()
139
+
140
+ const onData = (chunk: Buffer): void => {
141
+ buffer += chunk.toString('utf8')
142
+ const index = buffer.indexOf('\n')
143
+ if (index < 0)
144
+ return
145
+
146
+ const line = buffer.slice(0, index)
147
+ const rest = buffer.slice(index + 1)
148
+
149
+ let parsed: Partial<ClientHandshake>
150
+ try {
151
+ parsed = JSON.parse(line)
152
+ }
153
+ catch {
154
+ socket.destroy()
155
+ return
156
+ }
157
+
158
+ if (parsed.type !== 'handshake' || typeof parsed.token !== 'string') {
159
+ socket.destroy()
160
+ return
161
+ }
162
+
163
+ clearTimeout(timer)
164
+ socket.removeListener('data', onData)
165
+
166
+ if (parsed.token !== token) {
167
+ socket.end(`${JSON.stringify({ type: 'handshake', ok: false, reason: 'invalid token' })}\n`)
168
+ return
169
+ }
170
+
171
+ authenticated = true
172
+ socket.write(`${JSON.stringify({ type: 'handshake', ok: true, pid: process.pid })}\n`)
173
+ attachSocket(socket, runtime, log)
174
+
175
+ // Requests pipelined behind the handshake belong to the attached client.
176
+ if (rest.trim() !== '')
177
+ socket.emit('data', Buffer.from(rest))
178
+ }
179
+
180
+ socket.on('data', onData)
181
+ socket.on('error', () => {})
182
+ })
183
+
184
+ /**
185
+ * Exits once nobody needs the helper.
186
+ *
187
+ * Without this, every dev server that ever ran leaves a root process behind.
188
+ * The socket and token are removed too, but only while they still carry this
189
+ * process's identity: a newer helper may already own them.
190
+ */
191
+ function scheduleIdleExit(): void {
192
+ if (idleTimer || connections.size > 0)
193
+ return
194
+
195
+ idleTimer = setTimeout(() => {
196
+ idleTimer = undefined
197
+ if (connections.size > 0)
198
+ return
199
+
200
+ log('no clients left; shutting down')
201
+ removeOwnSocket(socketPath, token)
202
+
203
+ if (!exitOnIdle) {
204
+ server.close()
205
+ return
206
+ }
207
+
208
+ server.close(() => process.exit(0))
209
+ // A listener with no clients closes synchronously, but keep a backstop so
210
+ // the process cannot linger if a close callback never arrives.
211
+ setTimeout(() => process.exit(0), 1000).unref?.()
212
+ }, idleTimeoutMs)
213
+ idleTimer.unref?.()
214
+ }
215
+
216
+ await new Promise<void>((resolve, reject) => {
217
+ server.once('error', reject)
218
+ server.listen(socketPath, () => resolve())
219
+ })
220
+
221
+ // A helper nobody ever connects to is as useless as one whose clients left:
222
+ // start the timer now so a failed handshake does not strand it.
223
+ scheduleIdleExit()
224
+
225
+ try {
226
+ // The client is not root: hand the socket back to the developer and keep it
227
+ // private to them. Without the chown, `connect()` fails with EACCES and the
228
+ // client only sees a silent retry loop.
229
+ if (typeof options.ownerUid === 'number') {
230
+ chownSync(socketPath, options.ownerUid, options.ownerGid ?? options.ownerUid)
231
+ chmodSync(socketPath, 0o600)
232
+ }
233
+ else {
234
+ chmodSync(socketPath, 0o600)
235
+ }
236
+ }
237
+ catch (error) {
238
+ log(`could not set socket ownership: ${(error as Error).message}`)
239
+ }
240
+
241
+ return {
242
+ runtime,
243
+ async close() {
244
+ // Closing the listener alone would wait for clients to hang up first;
245
+ // shutdown has to drop the open connections as well.
246
+ for (const socket of connections)
247
+ socket.destroy()
248
+ connections.clear()
249
+ await new Promise<void>((resolve) => {
250
+ server.close(() => resolve())
251
+ })
252
+ },
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Removes this helper's socket and token, but only if they are still its own.
258
+ *
259
+ * A newer helper may have taken both over; deleting them then would break a
260
+ * session this process never served.
261
+ */
262
+ function removeOwnSocket(socketPath: string, token: string): void {
263
+ if (process.platform === 'win32')
264
+ return
265
+
266
+ try {
267
+ const tokensDir = dirname(socketPath)
268
+ const tokenFile = join(tokensDir, 'helper.token')
269
+ const current = existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined
270
+ if (current === token)
271
+ rmSync(tokenFile, { force: true })
272
+ }
273
+ catch {}
274
+
275
+ try {
276
+ rmSync(socketPath, { force: true })
277
+ }
278
+ catch {}
279
+ }
280
+
281
+ /** Sockets left behind by a killed helper cannot be reused; remove them first. */
282
+ export function removeStaleSocket(socketPath: string): void {
283
+ if (process.platform === 'win32')
284
+ return
285
+ try {
286
+ rmSync(socketPath, { force: true })
287
+ }
288
+ catch {}
289
+ }
@@ -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
+ }