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.
- package/README.md +30 -6
- package/dist/{chunk-RSBXFJ3Z.js → chunk-C5A7XLKL.js} +9 -2
- package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
- package/dist/chunk-RKHJN6KY.js +53 -0
- package/dist/{chunk-MD5CVZP7.js → chunk-U7KAIPZC.js} +982 -797
- package/dist/chunk-WOMR3ZLM.js +17 -0
- package/dist/esbuild.cjs +1086 -867
- package/dist/esbuild.js +5 -3
- package/dist/hmr-sse-bridge.cjs +12 -4
- package/dist/hmr-sse-bridge.d.cts +4 -3
- package/dist/hmr-sse-bridge.d.ts +4 -3
- package/dist/hmr-sse-bridge.js +5 -3
- package/dist/index.cjs +1086 -867
- package/dist/index.js +5 -3
- package/dist/nuxt.cjs +1057 -852
- package/dist/nuxt.js +6 -9
- package/dist/privilege-state.cjs +44 -0
- package/dist/privilege-state.d.cts +7 -0
- package/dist/privilege-state.d.ts +7 -0
- package/dist/privilege-state.js +10 -0
- package/dist/privileged-helper.cjs +824 -0
- package/dist/privileged-helper.d.cts +2 -0
- package/dist/privileged-helper.d.ts +2 -0
- package/dist/privileged-helper.js +797 -0
- package/dist/rollup.cjs +1086 -867
- package/dist/rollup.js +5 -3
- package/dist/rspack.cjs +1086 -867
- package/dist/rspack.js +5 -3
- 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 +1086 -867
- package/dist/vite.js +5 -3
- package/dist/webpack.cjs +1086 -867
- package/dist/webpack.js +5 -3
- package/package.json +1 -4
- package/src/caddy/caddyfile.ts +3 -0
- package/src/caddy/config.ts +195 -53
- package/src/caddy/constants.ts +30 -1
- package/src/caddy/index.ts +239 -206
- package/src/caddy/privileged.ts +124 -0
- package/src/caddy/utils.ts +0 -43
- package/src/hmr-sse-bridge.ts +14 -3
- package/src/index.ts +52 -25
- package/src/nuxt.ts +1 -6
- package/src/privilege-state.ts +23 -0
- package/src/privileged/bootstrap.ts +289 -0
- package/src/privileged/client.ts +288 -0
- package/src/privileged/entry.ts +73 -0
- package/src/privileged/hosts-file.ts +142 -0
- package/src/privileged/launch.ts +332 -0
- package/src/privileged/path-guard.ts +74 -0
- package/src/privileged/ports.ts +45 -0
- package/src/privileged/protocol.ts +111 -0
- package/src/privileged/server.ts +578 -0
- package/src/privileged/session.ts +169 -0
- package/src/privileged/validation.ts +38 -0
- package/src/privileged/windows-elevation.ts +80 -0
- package/src/privileged-helper.ts +123 -0
- package/src/utils.ts +0 -28
- 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
|
+
}
|