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.
- package/README.md +30 -6
- package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
- package/dist/chunk-RKHJN6KY.js +53 -0
- package/dist/{chunk-5R33PUJP.js → chunk-U7KAIPZC.js} +981 -796
- package/dist/chunk-WOMR3ZLM.js +17 -0
- package/dist/esbuild.cjs +1077 -864
- package/dist/esbuild.js +4 -2
- package/dist/index.cjs +1077 -864
- package/dist/index.js +4 -2
- package/dist/nuxt.cjs +1049 -850
- 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 +1077 -864
- package/dist/rollup.js +4 -2
- package/dist/rspack.cjs +1077 -864
- 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 +1077 -864
- package/dist/vite.js +4 -2
- package/dist/webpack.cjs +1077 -864
- package/dist/webpack.js +4 -2
- 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/index.ts +49 -22
- 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,124 @@
|
|
|
1
|
+
import type { PrivilegedClient } from '../privileged/client'
|
|
2
|
+
import type { HelperEvent } from '../privileged/protocol'
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { EventEmitter } from 'node:events'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import process from 'node:process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { resolveHelperEntry } from '../privileged/entry'
|
|
9
|
+
import { openHelperSession } from '../privileged/session'
|
|
10
|
+
|
|
11
|
+
export interface PrivilegedEnv {
|
|
12
|
+
/** Executable started as root, resolved from the running bundle. */
|
|
13
|
+
helperEntry: string
|
|
14
|
+
helperArgs: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolves what the helper needs to be started from the current bundle.
|
|
19
|
+
* `__dirname` exists in both the CJS and ESM outputs (tsup injects a shim).
|
|
20
|
+
*/
|
|
21
|
+
export function resolvePrivilegedEnv(): PrivilegedEnv {
|
|
22
|
+
const moduleDir = typeof __dirname === 'string'
|
|
23
|
+
? __dirname
|
|
24
|
+
: dirname(fileURLToPath(import.meta.url))
|
|
25
|
+
const resolved = resolveHelperEntry({ moduleDir })
|
|
26
|
+
return { helperEntry: resolved.entry, helperArgs: resolved.args }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Re-emits helper events as the `stdout`/`stderr` events the log parsing expects. */
|
|
30
|
+
export class HelperCaddyProcess extends EventEmitter {
|
|
31
|
+
readonly stdout = new EventEmitter()
|
|
32
|
+
readonly stderr = new EventEmitter()
|
|
33
|
+
private finished = false
|
|
34
|
+
private readonly unsubscribe: () => void
|
|
35
|
+
|
|
36
|
+
constructor(private readonly client: PrivilegedClient) {
|
|
37
|
+
super()
|
|
38
|
+
this.unsubscribe = client.onEvent(event => this.handleEvent(event))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private handleEvent(event: HelperEvent): void {
|
|
42
|
+
if (event.type !== 'event')
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if (event.kind === 'caddy.stdout') {
|
|
46
|
+
this.stdout.emit('data', Buffer.from(event.data))
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (event.kind === 'caddy.stderr') {
|
|
51
|
+
this.stderr.emit('data', Buffer.from(event.data))
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (event.kind === 'caddy.exit') {
|
|
56
|
+
this.finished = true
|
|
57
|
+
this.emit('exit', event.code, event.signal)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Asks the helper to stop Caddy; the helper also stops it on disconnect. */
|
|
62
|
+
kill(): void {
|
|
63
|
+
this.unsubscribe()
|
|
64
|
+
if (this.finished)
|
|
65
|
+
return
|
|
66
|
+
this.finished = true
|
|
67
|
+
void this.client.call('caddy.stop').catch(() => {})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface OpenedPrivilegedSession {
|
|
72
|
+
client: PrivilegedClient
|
|
73
|
+
reused: boolean
|
|
74
|
+
close: () => Promise<void>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PrivilegedSessionOptions {
|
|
78
|
+
log?: (message: string) => void
|
|
79
|
+
/** Runs the helper as the current user; tests use this to avoid sudo. */
|
|
80
|
+
direct?: boolean
|
|
81
|
+
/** Caddy binary the helper may run; fixed before the session opens. */
|
|
82
|
+
caddyBin: string
|
|
83
|
+
/** Caddyfile the helper may load. */
|
|
84
|
+
caddyConfigPath: string
|
|
85
|
+
/** Admin API socket; `ca.trust` talks to it instead of the TCP default. */
|
|
86
|
+
caddyAdminSocket?: string
|
|
87
|
+
/** Shown before the password prompt so the request is not a surprise. */
|
|
88
|
+
reason?: { target: string, https: boolean }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Where Caddy keeps its PKI, per platform. */
|
|
92
|
+
export function caddyStoragePath(): string {
|
|
93
|
+
return process.platform === 'win32'
|
|
94
|
+
? join(process.env.AppData ?? '', 'Caddy')
|
|
95
|
+
: join(process.env.HOME ?? '', 'Library/Application Support/Caddy')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Opens (or reuses) the privileged helper for this runtime directory.
|
|
100
|
+
* Only the helper runs as root: the plugin process, its caches and its
|
|
101
|
+
* `node_modules` keep the developer's identity.
|
|
102
|
+
*/
|
|
103
|
+
export function openPrivilegedSession(
|
|
104
|
+
runtimeDir: string,
|
|
105
|
+
options: PrivilegedSessionOptions,
|
|
106
|
+
): Promise<OpenedPrivilegedSession> {
|
|
107
|
+
const env = resolvePrivilegedEnv()
|
|
108
|
+
return openHelperSession({
|
|
109
|
+
runtimeDir,
|
|
110
|
+
helperEntry: env.helperEntry,
|
|
111
|
+
helperArgs: env.helperArgs,
|
|
112
|
+
direct: options.direct,
|
|
113
|
+
// Caddy keeps its PKI under the user's home; the helper runs it as root, so
|
|
114
|
+
// it must know which tree to hand back afterwards.
|
|
115
|
+
caddyStoragePath: caddyStoragePath(),
|
|
116
|
+
// The binary and Caddyfile are fixed here: the privileged helper must never
|
|
117
|
+
// execute a path supplied over the socket.
|
|
118
|
+
caddyBin: options.caddyBin,
|
|
119
|
+
caddyConfigPath: options.caddyConfigPath,
|
|
120
|
+
caddyAdminSocket: options.caddyAdminSocket,
|
|
121
|
+
reason: options.reason,
|
|
122
|
+
log: options.log,
|
|
123
|
+
})
|
|
124
|
+
}
|
package/src/caddy/utils.ts
CHANGED
|
@@ -1,51 +1,8 @@
|
|
|
1
|
-
import { exec } from 'node:child_process'
|
|
2
|
-
import { createServer } from 'node:net'
|
|
3
1
|
import ora from 'ora'
|
|
4
2
|
import { consola } from '../utils'
|
|
5
3
|
|
|
6
4
|
let spinner: ora.Ora
|
|
7
5
|
|
|
8
|
-
/**
|
|
9
|
-
* @returns whether the port is in use
|
|
10
|
-
*/
|
|
11
|
-
export function tryPort(port: number): Promise<boolean> {
|
|
12
|
-
return new Promise<boolean>((resolve, _reject) => {
|
|
13
|
-
try {
|
|
14
|
-
const server = createServer().listen(port)
|
|
15
|
-
server.on('listening', () => {
|
|
16
|
-
server.close()
|
|
17
|
-
resolve(false)
|
|
18
|
-
})
|
|
19
|
-
server.on('error', (err) => {
|
|
20
|
-
// @ts-expect-error err.code
|
|
21
|
-
if (err.code === 'EADDRINUSE')
|
|
22
|
-
resolve(true)
|
|
23
|
-
server.close()
|
|
24
|
-
})
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
resolve(true)
|
|
28
|
-
}
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* npx free-port :port
|
|
34
|
-
*/
|
|
35
|
-
export async function kill(port: number): Promise<void> {
|
|
36
|
-
return new Promise<void>((resolve, reject) => {
|
|
37
|
-
exec(`npx @maxbo/free-port ${port} -s`, (err, stdout, _stderr) => {
|
|
38
|
-
if (err) {
|
|
39
|
-
reject(err)
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
consola.log(stdout)
|
|
43
|
-
resolve()
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
})
|
|
47
|
-
}
|
|
48
|
-
|
|
49
6
|
function getNumber(number: number): string {
|
|
50
7
|
if (number <= 0)
|
|
51
8
|
return '0%'
|
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,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
|
+
}
|