unplugin-https-reverse-proxy 4.0.4 → 4.1.0-beta.3
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-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
- package/dist/{chunk-5R33PUJP.js → chunk-HBXMM25C.js} +765 -229
- package/dist/chunk-RKHJN6KY.js +53 -0
- package/dist/chunk-WOMR3ZLM.js +17 -0
- package/dist/esbuild.cjs +870 -306
- package/dist/esbuild.js +4 -2
- package/dist/index.cjs +870 -306
- package/dist/index.js +4 -2
- package/dist/nuxt.cjs +842 -292
- 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 +668 -0
- package/dist/privileged-helper.d.cts +2 -0
- package/dist/privileged-helper.d.ts +2 -0
- package/dist/privileged-helper.js +641 -0
- package/dist/rollup.cjs +870 -306
- package/dist/rollup.js +4 -2
- package/dist/rspack.cjs +870 -306
- package/dist/rspack.js +4 -2
- 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 +870 -306
- package/dist/vite.js +4 -2
- package/dist/webpack.cjs +870 -306
- package/dist/webpack.js +4 -2
- package/package.json +1 -4
- package/src/caddy/caddyfile.ts +1 -0
- package/src/caddy/index.ts +184 -110
- package/src/caddy/privileged.ts +121 -0
- package/src/caddy/utils.ts +0 -43
- package/src/index.ts +49 -22
- package/src/nuxt.ts +1 -6
- package/src/privilege-state.ts +23 -0
- package/src/privileged/bootstrap.ts +174 -0
- package/src/privileged/client.ts +287 -0
- package/src/privileged/entry.ts +73 -0
- package/src/privileged/hosts-file.ts +142 -0
- package/src/privileged/launch.ts +235 -0
- package/src/privileged/ports.ts +45 -0
- package/src/privileged/protocol.ts +111 -0
- package/src/privileged/server.ts +465 -0
- package/src/privileged/session.ts +117 -0
- package/src/privileged/validation.ts +38 -0
- package/src/privileged/windows-elevation.ts +80 -0
- package/src/privileged-helper.ts +109 -0
- package/src/utils.ts +0 -28
- package/src/host/index.ts +0 -52
package/src/index.ts
CHANGED
|
@@ -7,7 +7,9 @@ import c from 'picocolors'
|
|
|
7
7
|
import { createUnplugin } from 'unplugin'
|
|
8
8
|
import { CaddyInstant } from './caddy'
|
|
9
9
|
import { hmrSseBridgeClientScript, setupHmrSseBridge } from './hmr-sse-bridge'
|
|
10
|
-
import {
|
|
10
|
+
import { isPrivilegeDenied, markPrivilegeDenied } from './privilege-state'
|
|
11
|
+
import { PrivilegeDeniedError } from './privileged/launch'
|
|
12
|
+
import { consola, generateQrcode, getIpv4List, once } from './utils'
|
|
11
13
|
|
|
12
14
|
let config: ResolvedConfig
|
|
13
15
|
|
|
@@ -62,10 +64,15 @@ export const vitePrintUrls = once((
|
|
|
62
64
|
) => {
|
|
63
65
|
_printUrls?.()
|
|
64
66
|
|
|
67
|
+
// Declined earlier in this process; a hot restart must not ask again.
|
|
68
|
+
if (isPrivilegeDenied())
|
|
69
|
+
return
|
|
70
|
+
|
|
65
71
|
if (caddy)
|
|
66
72
|
return
|
|
67
|
-
|
|
68
|
-
caddy
|
|
73
|
+
const instance = new CaddyInstant()
|
|
74
|
+
caddy = instance
|
|
75
|
+
instance.run(source, target, {
|
|
69
76
|
base,
|
|
70
77
|
...options,
|
|
71
78
|
}).then(async () => {
|
|
@@ -73,6 +80,16 @@ export const vitePrintUrls = once((
|
|
|
73
80
|
|
|
74
81
|
consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
|
|
75
82
|
}).catch((e) => {
|
|
83
|
+
// Declining the password is a choice, not a crash: drop the half-started
|
|
84
|
+
// instance so a later attempt can prompt again.
|
|
85
|
+
if (caddy === instance)
|
|
86
|
+
caddy = undefined as unknown as CaddyInstant
|
|
87
|
+
if (e instanceof PrivilegeDeniedError) {
|
|
88
|
+
markPrivilegeDenied()
|
|
89
|
+
consola.warn('reverse proxy skipped: administrator permission was not granted.')
|
|
90
|
+
consola.info('Restart the dev server to enable it again.')
|
|
91
|
+
return
|
|
92
|
+
}
|
|
76
93
|
throw e
|
|
77
94
|
})
|
|
78
95
|
})
|
|
@@ -96,10 +113,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
96
113
|
if (!enable)
|
|
97
114
|
return
|
|
98
115
|
|
|
99
|
-
if (!isAdmin()) {
|
|
100
|
-
consola.warn('please run as administrator')
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
116
|
if (!target) {
|
|
104
117
|
consola.fail('please provide target')
|
|
105
118
|
return
|
|
@@ -153,11 +166,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
153
166
|
if (!enable)
|
|
154
167
|
return
|
|
155
168
|
|
|
156
|
-
if (!isAdmin()) {
|
|
157
|
-
consola.warn('please run as administrator')
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
|
|
161
169
|
if (!target) {
|
|
162
170
|
consola.fail('please provide target')
|
|
163
171
|
return
|
|
@@ -177,6 +185,9 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
177
185
|
}
|
|
178
186
|
const source = `${devServer.host}:${devServer.port}`
|
|
179
187
|
|
|
188
|
+
if (isPrivilegeDenied())
|
|
189
|
+
return
|
|
190
|
+
|
|
180
191
|
if (caddy)
|
|
181
192
|
return
|
|
182
193
|
|
|
@@ -204,9 +215,10 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
204
215
|
options.https = false
|
|
205
216
|
}
|
|
206
217
|
|
|
207
|
-
|
|
218
|
+
const instance = new CaddyInstant()
|
|
219
|
+
caddy = instance
|
|
208
220
|
|
|
209
|
-
|
|
221
|
+
instance.run(source, target, {
|
|
210
222
|
base,
|
|
211
223
|
...options,
|
|
212
224
|
}).then(() => {
|
|
@@ -216,6 +228,14 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
216
228
|
consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
|
|
217
229
|
})
|
|
218
230
|
}).catch((e) => {
|
|
231
|
+
if (caddy === instance)
|
|
232
|
+
caddy = undefined as unknown as CaddyInstant
|
|
233
|
+
if (e instanceof PrivilegeDeniedError) {
|
|
234
|
+
markPrivilegeDenied()
|
|
235
|
+
consola.warn('reverse proxy skipped: administrator permission was not granted.')
|
|
236
|
+
consola.info('Restart the dev server to enable it again.')
|
|
237
|
+
return
|
|
238
|
+
}
|
|
219
239
|
throw e
|
|
220
240
|
})
|
|
221
241
|
},
|
|
@@ -231,11 +251,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
231
251
|
if (!enable)
|
|
232
252
|
return
|
|
233
253
|
|
|
234
|
-
if (!isAdmin()) {
|
|
235
|
-
consola.warn('please run as administrator')
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
|
|
239
254
|
if (!target) {
|
|
240
255
|
consola.fail('please provide target')
|
|
241
256
|
return
|
|
@@ -257,10 +272,11 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
257
272
|
|
|
258
273
|
const source = `${devServer.host}:${devServer.port}`
|
|
259
274
|
|
|
260
|
-
if (
|
|
275
|
+
if (isPrivilegeDenied())
|
|
261
276
|
return
|
|
262
277
|
|
|
263
|
-
|
|
278
|
+
if (caddy)
|
|
279
|
+
return
|
|
264
280
|
|
|
265
281
|
let base = '/'
|
|
266
282
|
const publicPath = compiler.options.output?.publicPath as unknown
|
|
@@ -272,7 +288,10 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
272
288
|
if (base !== '/' && !base.endsWith('/'))
|
|
273
289
|
base = `${base}/`
|
|
274
290
|
|
|
275
|
-
|
|
291
|
+
const instance = new CaddyInstant()
|
|
292
|
+
caddy = instance
|
|
293
|
+
|
|
294
|
+
instance.run(source, target, {
|
|
276
295
|
base,
|
|
277
296
|
...options,
|
|
278
297
|
}).then(async () => {
|
|
@@ -280,6 +299,14 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
280
299
|
|
|
281
300
|
consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
|
|
282
301
|
}).catch((e) => {
|
|
302
|
+
if (caddy === instance)
|
|
303
|
+
caddy = undefined as unknown as CaddyInstant
|
|
304
|
+
if (e instanceof PrivilegeDeniedError) {
|
|
305
|
+
markPrivilegeDenied()
|
|
306
|
+
consola.warn('reverse proxy skipped: administrator permission was not granted.')
|
|
307
|
+
consola.info('Restart the dev server to enable it again.')
|
|
308
|
+
return
|
|
309
|
+
}
|
|
283
310
|
throw e
|
|
284
311
|
})
|
|
285
312
|
},
|
package/src/nuxt.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Options } from './types'
|
|
|
2
2
|
import process from 'node:process'
|
|
3
3
|
import { defineNuxtModule } from '@nuxt/kit'
|
|
4
4
|
import { vitePrintUrls } from '.'
|
|
5
|
-
import { consola
|
|
5
|
+
import { consola } from './utils'
|
|
6
6
|
import '@nuxt/schema'
|
|
7
7
|
|
|
8
8
|
export interface ModuleOptions extends Options {
|
|
@@ -26,11 +26,6 @@ export default defineNuxtModule<ModuleOptions>({
|
|
|
26
26
|
if (!enable)
|
|
27
27
|
return
|
|
28
28
|
|
|
29
|
-
if (!isAdmin()) {
|
|
30
|
-
consola.warn('please run as administrator')
|
|
31
|
-
return
|
|
32
|
-
}
|
|
33
|
-
|
|
34
29
|
if (!target) {
|
|
35
30
|
consola.fail('please provide target')
|
|
36
31
|
return
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the user dismissed the password prompt in this process.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module so the flag outlives a hot restart. Vite rebuilds the
|
|
5
|
+
* dev server in place (`Object.assign(server, newServer)`) rather than starting
|
|
6
|
+
* a new process, so module scope is the only place that distinguishes "saved
|
|
7
|
+
* the config again" from "started the dev server again".
|
|
8
|
+
*/
|
|
9
|
+
let denied = false
|
|
10
|
+
|
|
11
|
+
/** Records that the user declined; every entry point checks this first. */
|
|
12
|
+
export function markPrivilegeDenied(): void {
|
|
13
|
+
denied = true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isPrivilegeDenied(): boolean {
|
|
17
|
+
return denied
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Test hook: restores the initial state between cases. */
|
|
21
|
+
export function resetPrivilegeDenied(): void {
|
|
22
|
+
denied = false
|
|
23
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
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, rmSync } from 'node:fs'
|
|
6
|
+
import { createServer } from 'node:net'
|
|
7
|
+
import process from 'node:process'
|
|
8
|
+
import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server'
|
|
9
|
+
|
|
10
|
+
export interface HelperBootstrapOptions {
|
|
11
|
+
socketPath: string
|
|
12
|
+
token: string
|
|
13
|
+
hostsPath?: string
|
|
14
|
+
/** Caddy binary; fixed here so the client never names an executable. */
|
|
15
|
+
caddyBin: string
|
|
16
|
+
/** Caddyfile to load; fixed for the same reason. */
|
|
17
|
+
caddyConfigPath: string
|
|
18
|
+
caddyCwd?: string
|
|
19
|
+
caddyEnv?: Record<string, string>
|
|
20
|
+
/**
|
|
21
|
+
* Owner of the socket file. The helper runs as root while its client runs as
|
|
22
|
+
* the developer, so the socket must be handed back or the client cannot
|
|
23
|
+
* connect to it.
|
|
24
|
+
*/
|
|
25
|
+
ownerUid?: number
|
|
26
|
+
ownerGid?: number
|
|
27
|
+
/** Caddy storage directory, handed back to the owner when Caddy stops. */
|
|
28
|
+
caddyStoragePath?: string
|
|
29
|
+
deps?: HelperDeps
|
|
30
|
+
log?: (message: string) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RunningHelper {
|
|
34
|
+
runtime: HelperRuntime
|
|
35
|
+
close: () => Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const HANDSHAKE_TIMEOUT_MS = 120_000
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Runs the privileged side of the plugin: listens on a local socket, accepts
|
|
42
|
+
* only a client presenting the shared token, then serves until that client
|
|
43
|
+
* disconnects. Losing the client is the cleanup trigger, so a crashed or
|
|
44
|
+
* `SIGKILL`ed plugin still leaves hosts and Caddy restored.
|
|
45
|
+
*/
|
|
46
|
+
export async function startHelper(options: HelperBootstrapOptions): Promise<RunningHelper> {
|
|
47
|
+
const { socketPath, token } = options
|
|
48
|
+
const log = options.log ?? ((message: string) => process.stdout.write(`[uhrp-helper] ${message}\n`))
|
|
49
|
+
const deps = options.deps ?? createNodeHelperDeps({
|
|
50
|
+
hostsPath: options.hostsPath,
|
|
51
|
+
log,
|
|
52
|
+
caddyBin: options.caddyBin,
|
|
53
|
+
caddyConfigPath: options.caddyConfigPath,
|
|
54
|
+
caddyCwd: options.caddyCwd,
|
|
55
|
+
caddyEnv: options.caddyEnv,
|
|
56
|
+
caddyStoragePath: options.caddyStoragePath,
|
|
57
|
+
storageOwner: typeof options.ownerUid === 'number'
|
|
58
|
+
? { uid: options.ownerUid, gid: options.ownerGid ?? options.ownerUid }
|
|
59
|
+
: undefined,
|
|
60
|
+
})
|
|
61
|
+
const runtime = createHelperRuntime(deps)
|
|
62
|
+
|
|
63
|
+
// A previous session that was killed cannot have cleaned up its entries; do
|
|
64
|
+
// it now, before any client attaches.
|
|
65
|
+
await runtime.sweepStaleEntries().catch(() => {})
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
rmSync(socketPath, { force: true })
|
|
69
|
+
}
|
|
70
|
+
catch {}
|
|
71
|
+
|
|
72
|
+
const connections = new Set<Socket>()
|
|
73
|
+
|
|
74
|
+
const server = createServer((socket) => {
|
|
75
|
+
let buffer = ''
|
|
76
|
+
let authenticated = false
|
|
77
|
+
connections.add(socket)
|
|
78
|
+
socket.on('close', () => connections.delete(socket))
|
|
79
|
+
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
if (!authenticated)
|
|
82
|
+
socket.destroy()
|
|
83
|
+
}, HANDSHAKE_TIMEOUT_MS)
|
|
84
|
+
timer.unref?.()
|
|
85
|
+
|
|
86
|
+
const onData = (chunk: Buffer): void => {
|
|
87
|
+
buffer += chunk.toString('utf8')
|
|
88
|
+
const index = buffer.indexOf('\n')
|
|
89
|
+
if (index < 0)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
const line = buffer.slice(0, index)
|
|
93
|
+
const rest = buffer.slice(index + 1)
|
|
94
|
+
|
|
95
|
+
let parsed: Partial<ClientHandshake>
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(line)
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
socket.destroy()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (parsed.type !== 'handshake' || typeof parsed.token !== 'string') {
|
|
105
|
+
socket.destroy()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
socket.removeListener('data', onData)
|
|
111
|
+
|
|
112
|
+
if (parsed.token !== token) {
|
|
113
|
+
socket.end(`${JSON.stringify({ type: 'handshake', ok: false, reason: 'invalid token' })}\n`)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
authenticated = true
|
|
118
|
+
socket.write(`${JSON.stringify({ type: 'handshake', ok: true, pid: process.pid })}\n`)
|
|
119
|
+
attachSocket(socket, runtime, log)
|
|
120
|
+
|
|
121
|
+
// Requests pipelined behind the handshake belong to the attached client.
|
|
122
|
+
if (rest.trim() !== '')
|
|
123
|
+
socket.emit('data', Buffer.from(rest))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
socket.on('data', onData)
|
|
127
|
+
socket.on('error', () => {})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await new Promise<void>((resolve, reject) => {
|
|
131
|
+
server.once('error', reject)
|
|
132
|
+
server.listen(socketPath, () => resolve())
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// The client is not root: hand the socket back to the developer and keep it
|
|
137
|
+
// private to them. Without the chown, `connect()` fails with EACCES and the
|
|
138
|
+
// client only sees a silent retry loop.
|
|
139
|
+
if (typeof options.ownerUid === 'number') {
|
|
140
|
+
chownSync(socketPath, options.ownerUid, options.ownerGid ?? options.ownerUid)
|
|
141
|
+
chmodSync(socketPath, 0o600)
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
chmodSync(socketPath, 0o600)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
log(`could not set socket ownership: ${(error as Error).message}`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
runtime,
|
|
153
|
+
async close() {
|
|
154
|
+
// Closing the listener alone would wait for clients to hang up first;
|
|
155
|
+
// shutdown has to drop the open connections as well.
|
|
156
|
+
for (const socket of connections)
|
|
157
|
+
socket.destroy()
|
|
158
|
+
connections.clear()
|
|
159
|
+
await new Promise<void>((resolve) => {
|
|
160
|
+
server.close(() => resolve())
|
|
161
|
+
})
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Sockets left behind by a killed helper cannot be reused; remove them first. */
|
|
167
|
+
export function removeStaleSocket(socketPath: string): void {
|
|
168
|
+
if (process.platform === 'win32')
|
|
169
|
+
return
|
|
170
|
+
try {
|
|
171
|
+
rmSync(socketPath, { force: true })
|
|
172
|
+
}
|
|
173
|
+
catch {}
|
|
174
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
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
|
+
'caddy.start': { pid?: number }
|
|
48
|
+
'caddy.stop': void
|
|
49
|
+
'ca.trust': { trusted: boolean, output: string }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class HelperConnectionError extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message)
|
|
55
|
+
this.name = 'HelperConnectionError'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PrivilegedClient {
|
|
60
|
+
readonly connected: boolean
|
|
61
|
+
call: <M extends keyof HelperResults>(method: M, ...params: HelperParams<M>) => Promise<HelperResults[M]>
|
|
62
|
+
onEvent: (listener: (event: HelperEvent) => void) => () => void
|
|
63
|
+
close: () => Promise<void>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type HelperParams<M extends keyof HelperResults>
|
|
67
|
+
= M extends 'hosts.add' | 'hosts.remove' ? [params: { ip: string, host: string }]
|
|
68
|
+
: M extends 'port.free' ? [params: { port: number }]
|
|
69
|
+
: []
|
|
70
|
+
|
|
71
|
+
interface PendingCall {
|
|
72
|
+
resolve: (value: unknown) => void
|
|
73
|
+
reject: (error: Error) => void
|
|
74
|
+
timer: ReturnType<typeof setTimeout>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ConnectOptions {
|
|
78
|
+
socketPath: string
|
|
79
|
+
token: string
|
|
80
|
+
/** Total time to wait for the helper to accept the connection. */
|
|
81
|
+
timeoutMs?: number
|
|
82
|
+
retryIntervalMs?: number
|
|
83
|
+
callTimeoutMs?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function openSocket(socketPath: string): Promise<Socket> {
|
|
87
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
88
|
+
const socket = connect(socketPath)
|
|
89
|
+
|
|
90
|
+
function onConnect(): void {
|
|
91
|
+
socket.removeListener('error', onError)
|
|
92
|
+
resolve(socket)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onError(error: Error): void {
|
|
96
|
+
socket.removeListener('connect', onConnect)
|
|
97
|
+
reject(error)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
socket.once('error', onError)
|
|
101
|
+
socket.once('connect', onConnect)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Connects to an already-running helper and performs the token handshake.
|
|
107
|
+
* Retries because the helper may still be starting while sudo asks for a password.
|
|
108
|
+
*/
|
|
109
|
+
export async function connectClient(options: ConnectOptions): Promise<PrivilegedClient> {
|
|
110
|
+
const {
|
|
111
|
+
socketPath,
|
|
112
|
+
token,
|
|
113
|
+
timeoutMs = 120_000,
|
|
114
|
+
retryIntervalMs = 100,
|
|
115
|
+
callTimeoutMs = 30_000,
|
|
116
|
+
} = options
|
|
117
|
+
|
|
118
|
+
const deadline = Date.now() + timeoutMs
|
|
119
|
+
let lastError: Error | undefined
|
|
120
|
+
|
|
121
|
+
while (Date.now() < deadline) {
|
|
122
|
+
try {
|
|
123
|
+
return await handshake(socketPath, token, callTimeoutMs)
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
lastError = error as Error
|
|
127
|
+
|
|
128
|
+
// A permission or protocol error will not resolve by waiting, and
|
|
129
|
+
// retrying hides the real cause behind a silent hang.
|
|
130
|
+
const code = (lastError as NodeJS.ErrnoException).code
|
|
131
|
+
if (code === 'EACCES' || lastError instanceof HelperConnectionError)
|
|
132
|
+
throw lastError
|
|
133
|
+
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, retryIntervalMs))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new HelperConnectionError(
|
|
139
|
+
`privileged helper is not reachable at ${socketPath}: ${lastError?.message ?? 'timed out'}`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function handshake(socketPath: string, token: string, callTimeoutMs: number): Promise<PrivilegedClient> {
|
|
144
|
+
return new Promise<PrivilegedClient>((resolve, reject) => {
|
|
145
|
+
void openSocket(socketPath).then((socket) => {
|
|
146
|
+
let buffer = ''
|
|
147
|
+
let settled = false
|
|
148
|
+
|
|
149
|
+
const fail = (error: Error): void => {
|
|
150
|
+
if (settled)
|
|
151
|
+
return
|
|
152
|
+
settled = true
|
|
153
|
+
socket.destroy()
|
|
154
|
+
reject(error)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const onData = (chunk: Buffer): void => {
|
|
158
|
+
if (settled)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
const split = splitLines(buffer, chunk.toString('utf8'))
|
|
162
|
+
buffer = split.rest
|
|
163
|
+
const line = split.lines[0]
|
|
164
|
+
if (line === undefined)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
let hello: ServerHandshake
|
|
168
|
+
try {
|
|
169
|
+
hello = JSON.parse(line) as ServerHandshake
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
return fail(new HelperConnectionError(`invalid handshake reply: ${(error as Error).message}`))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (hello.type !== 'handshake')
|
|
176
|
+
return fail(new HelperConnectionError('helper did not answer the handshake'))
|
|
177
|
+
|
|
178
|
+
if (!hello.ok)
|
|
179
|
+
return fail(new HelperConnectionError(hello.reason ?? 'helper rejected the handshake'))
|
|
180
|
+
|
|
181
|
+
settled = true
|
|
182
|
+
socket.removeListener('data', onData)
|
|
183
|
+
socket.removeListener('error', onError)
|
|
184
|
+
const remaining = split.lines.slice(1)
|
|
185
|
+
resolve(createClient(socket, [split.rest, ...remaining].filter(Boolean).join('\n'), callTimeoutMs))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function onError(error: Error): void {
|
|
189
|
+
fail(error)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
socket.on('data', onData)
|
|
193
|
+
socket.on('error', onError)
|
|
194
|
+
|
|
195
|
+
const greeting: ClientHandshake = { type: 'handshake', token }
|
|
196
|
+
socket.write(serializeMessage(greeting))
|
|
197
|
+
}, reject)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function createClient(socket: Socket, initialBuffer: string, callTimeoutMs: number): PrivilegedClient {
|
|
202
|
+
let buffer = initialBuffer
|
|
203
|
+
let connected = true
|
|
204
|
+
let nextId: RpcId = 1
|
|
205
|
+
|
|
206
|
+
const pending = new Map<RpcId, PendingCall>()
|
|
207
|
+
const listeners = new Set<(event: HelperEvent) => void>()
|
|
208
|
+
|
|
209
|
+
socket.on('data', (chunk: Buffer) => {
|
|
210
|
+
const split = splitLines(buffer, chunk.toString('utf8'))
|
|
211
|
+
buffer = split.rest
|
|
212
|
+
|
|
213
|
+
for (const line of split.lines) {
|
|
214
|
+
const decoded = decodeMessage(line)
|
|
215
|
+
if (!decoded.ok)
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
const message: HelperMessage = decoded.message
|
|
219
|
+
if (!('id' in message)) {
|
|
220
|
+
for (const listener of listeners)
|
|
221
|
+
listener(message)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const entry = pending.get(message.id)
|
|
226
|
+
if (!entry)
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
pending.delete(message.id)
|
|
230
|
+
clearTimeout(entry.timer)
|
|
231
|
+
|
|
232
|
+
if (message.ok)
|
|
233
|
+
entry.resolve(message.result)
|
|
234
|
+
else
|
|
235
|
+
entry.reject(new Error(message.error.message))
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const disconnect = (): void => {
|
|
240
|
+
if (!connected)
|
|
241
|
+
return
|
|
242
|
+
connected = false
|
|
243
|
+
for (const [id, entry] of pending) {
|
|
244
|
+
clearTimeout(entry.timer)
|
|
245
|
+
pending.delete(id)
|
|
246
|
+
entry.reject(new HelperConnectionError('privileged helper connection closed'))
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
socket.on('close', disconnect)
|
|
251
|
+
socket.on('error', disconnect)
|
|
252
|
+
|
|
253
|
+
function call(method: keyof HelperResults, params?: unknown): Promise<unknown> {
|
|
254
|
+
if (!connected)
|
|
255
|
+
return Promise.reject(new HelperConnectionError('privileged helper is not connected'))
|
|
256
|
+
|
|
257
|
+
const id = nextId++
|
|
258
|
+
const request = { id, method, params } as unknown as HelperRequest
|
|
259
|
+
|
|
260
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
261
|
+
const timer = setTimeout(() => {
|
|
262
|
+
pending.delete(id)
|
|
263
|
+
reject(new HelperConnectionError(`privileged helper did not answer "${method}" in time`))
|
|
264
|
+
}, callTimeoutMs)
|
|
265
|
+
|
|
266
|
+
pending.set(id, { resolve, reject, timer })
|
|
267
|
+
socket.write(serializeMessage(request))
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
get connected() {
|
|
273
|
+
return connected
|
|
274
|
+
},
|
|
275
|
+
call: call as PrivilegedClient['call'],
|
|
276
|
+
onEvent(listener) {
|
|
277
|
+
listeners.add(listener)
|
|
278
|
+
return () => {
|
|
279
|
+
listeners.delete(listener)
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
async close() {
|
|
283
|
+
disconnect()
|
|
284
|
+
socket.destroy()
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
}
|