unplugin-https-reverse-proxy 4.1.0-beta.2 → 4.1.0-beta.4
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/dist/{chunk-7CA3EIIX.js → chunk-H7LIW3S4.js} +345 -665
- package/dist/chunk-WOMR3ZLM.js +17 -0
- package/dist/esbuild.cjs +350 -665
- package/dist/esbuild.js +2 -1
- package/dist/index.cjs +350 -665
- package/dist/index.js +2 -1
- package/dist/nuxt.cjs +322 -659
- package/dist/nuxt.js +2 -1
- 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 +110 -13
- package/dist/privileged-helper.js +111 -14
- package/dist/rollup.cjs +350 -665
- package/dist/rollup.js +2 -1
- package/dist/rspack.cjs +350 -665
- package/dist/rspack.js +2 -1
- package/dist/vite.cjs +350 -665
- package/dist/vite.js +2 -1
- package/dist/webpack.cjs +350 -665
- package/dist/webpack.js +2 -1
- package/package.json +1 -1
- package/src/caddy/config.ts +176 -57
- package/src/caddy/index.ts +89 -131
- package/src/caddy/privileged.ts +3 -0
- package/src/index.ts +48 -7
- package/src/privilege-state.ts +23 -0
- package/src/privileged/bootstrap.ts +83 -2
- package/src/privileged/entry.ts +34 -6
- package/src/privileged/launch.ts +46 -4
- package/src/privileged/server.ts +95 -14
- package/src/privileged/session.ts +66 -14
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ 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 { isPrivilegeDenied, markPrivilegeDenied } from './privilege-state'
|
|
11
|
+
import { PrivilegeDeniedError } from './privileged/launch'
|
|
10
12
|
import { consola, generateQrcode, getIpv4List, once } from './utils'
|
|
11
13
|
|
|
12
14
|
let config: ResolvedConfig
|
|
@@ -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
|
})
|
|
@@ -168,6 +185,9 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
168
185
|
}
|
|
169
186
|
const source = `${devServer.host}:${devServer.port}`
|
|
170
187
|
|
|
188
|
+
if (isPrivilegeDenied())
|
|
189
|
+
return
|
|
190
|
+
|
|
171
191
|
if (caddy)
|
|
172
192
|
return
|
|
173
193
|
|
|
@@ -195,9 +215,10 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
195
215
|
options.https = false
|
|
196
216
|
}
|
|
197
217
|
|
|
198
|
-
|
|
218
|
+
const instance = new CaddyInstant()
|
|
219
|
+
caddy = instance
|
|
199
220
|
|
|
200
|
-
|
|
221
|
+
instance.run(source, target, {
|
|
201
222
|
base,
|
|
202
223
|
...options,
|
|
203
224
|
}).then(() => {
|
|
@@ -207,6 +228,14 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
207
228
|
consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
|
|
208
229
|
})
|
|
209
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
|
+
}
|
|
210
239
|
throw e
|
|
211
240
|
})
|
|
212
241
|
},
|
|
@@ -243,10 +272,11 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
243
272
|
|
|
244
273
|
const source = `${devServer.host}:${devServer.port}`
|
|
245
274
|
|
|
246
|
-
if (
|
|
275
|
+
if (isPrivilegeDenied())
|
|
247
276
|
return
|
|
248
277
|
|
|
249
|
-
|
|
278
|
+
if (caddy)
|
|
279
|
+
return
|
|
250
280
|
|
|
251
281
|
let base = '/'
|
|
252
282
|
const publicPath = compiler.options.output?.publicPath as unknown
|
|
@@ -258,7 +288,10 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
258
288
|
if (base !== '/' && !base.endsWith('/'))
|
|
259
289
|
base = `${base}/`
|
|
260
290
|
|
|
261
|
-
|
|
291
|
+
const instance = new CaddyInstant()
|
|
292
|
+
caddy = instance
|
|
293
|
+
|
|
294
|
+
instance.run(source, target, {
|
|
262
295
|
base,
|
|
263
296
|
...options,
|
|
264
297
|
}).then(async () => {
|
|
@@ -266,6 +299,14 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
266
299
|
|
|
267
300
|
consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
|
|
268
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
|
+
}
|
|
269
310
|
throw e
|
|
270
311
|
})
|
|
271
312
|
},
|
|
@@ -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
|
+
}
|
|
@@ -2,8 +2,9 @@ import type { Socket } from 'node:net'
|
|
|
2
2
|
import type { ClientHandshake } from './protocol'
|
|
3
3
|
import type { HelperDeps, HelperRuntime } from './server'
|
|
4
4
|
import { Buffer } from 'node:buffer'
|
|
5
|
-
import { chmodSync, chownSync, rmSync } from 'node:fs'
|
|
5
|
+
import { chmodSync, chownSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
6
6
|
import { createServer } from 'node:net'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
7
8
|
import process from 'node:process'
|
|
8
9
|
import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server'
|
|
9
10
|
|
|
@@ -26,6 +27,14 @@ export interface HelperBootstrapOptions {
|
|
|
26
27
|
ownerGid?: number
|
|
27
28
|
/** Caddy storage directory, handed back to the owner when Caddy stops. */
|
|
28
29
|
caddyStoragePath?: string
|
|
30
|
+
/**
|
|
31
|
+
* How long the helper lingers with no clients before exiting.
|
|
32
|
+
*
|
|
33
|
+
* Kept short so a helper nobody is using does not stay on the machine as an
|
|
34
|
+
* idle root process. Within the same terminal a restart usually happens
|
|
35
|
+
* sooner than this and still reuses the running helper.
|
|
36
|
+
*/
|
|
37
|
+
idleTimeoutMs?: number
|
|
29
38
|
deps?: HelperDeps
|
|
30
39
|
log?: (message: string) => void
|
|
31
40
|
}
|
|
@@ -37,6 +46,9 @@ export interface RunningHelper {
|
|
|
37
46
|
|
|
38
47
|
const HANDSHAKE_TIMEOUT_MS = 120_000
|
|
39
48
|
|
|
49
|
+
/** Default idle lifetime after the last client leaves. */
|
|
50
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 30_000
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Runs the privileged side of the plugin: listens on a local socket, accepts
|
|
42
54
|
* only a client presenting the shared token, then serves until that client
|
|
@@ -70,12 +82,26 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
|
|
|
70
82
|
catch {}
|
|
71
83
|
|
|
72
84
|
const connections = new Set<Socket>()
|
|
85
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
86
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
87
|
+
|
|
88
|
+
const stopIdleTimer = (): void => {
|
|
89
|
+
if (idleTimer) {
|
|
90
|
+
clearTimeout(idleTimer)
|
|
91
|
+
idleTimer = undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
73
94
|
|
|
74
95
|
const server = createServer((socket) => {
|
|
75
96
|
let buffer = ''
|
|
76
97
|
let authenticated = false
|
|
98
|
+
stopIdleTimer()
|
|
77
99
|
connections.add(socket)
|
|
78
|
-
socket.on('close', () =>
|
|
100
|
+
socket.on('close', () => {
|
|
101
|
+
connections.delete(socket)
|
|
102
|
+
if (connections.size === 0)
|
|
103
|
+
scheduleIdleExit()
|
|
104
|
+
})
|
|
79
105
|
|
|
80
106
|
const timer = setTimeout(() => {
|
|
81
107
|
if (!authenticated)
|
|
@@ -127,11 +153,41 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
|
|
|
127
153
|
socket.on('error', () => {})
|
|
128
154
|
})
|
|
129
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Exits once nobody needs the helper.
|
|
158
|
+
*
|
|
159
|
+
* Without this, every dev server that ever ran leaves a root process behind.
|
|
160
|
+
* The socket and token are removed too, but only while they still carry this
|
|
161
|
+
* process's identity: a newer helper may already own them.
|
|
162
|
+
*/
|
|
163
|
+
function scheduleIdleExit(): void {
|
|
164
|
+
if (idleTimer || connections.size > 0)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
idleTimer = setTimeout(() => {
|
|
168
|
+
idleTimer = undefined
|
|
169
|
+
if (connections.size > 0)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
log('no clients left; shutting down')
|
|
173
|
+
removeOwnSocket(socketPath, token)
|
|
174
|
+
server.close(() => process.exit(0))
|
|
175
|
+
// A listener with no clients closes synchronously, but keep a backstop so
|
|
176
|
+
// the process cannot linger if a close callback never arrives.
|
|
177
|
+
setTimeout(() => process.exit(0), 1000).unref?.()
|
|
178
|
+
}, idleTimeoutMs)
|
|
179
|
+
idleTimer.unref?.()
|
|
180
|
+
}
|
|
181
|
+
|
|
130
182
|
await new Promise<void>((resolve, reject) => {
|
|
131
183
|
server.once('error', reject)
|
|
132
184
|
server.listen(socketPath, () => resolve())
|
|
133
185
|
})
|
|
134
186
|
|
|
187
|
+
// A helper nobody ever connects to is as useless as one whose clients left:
|
|
188
|
+
// start the timer now so a failed handshake does not strand it.
|
|
189
|
+
scheduleIdleExit()
|
|
190
|
+
|
|
135
191
|
try {
|
|
136
192
|
// The client is not root: hand the socket back to the developer and keep it
|
|
137
193
|
// private to them. Without the chown, `connect()` fails with EACCES and the
|
|
@@ -163,6 +219,31 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
|
|
|
163
219
|
}
|
|
164
220
|
}
|
|
165
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Removes this helper's socket and token, but only if they are still its own.
|
|
224
|
+
*
|
|
225
|
+
* A newer helper may have taken both over; deleting them then would break a
|
|
226
|
+
* session this process never served.
|
|
227
|
+
*/
|
|
228
|
+
function removeOwnSocket(socketPath: string, token: string): void {
|
|
229
|
+
if (process.platform === 'win32')
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const tokensDir = dirname(socketPath)
|
|
234
|
+
const tokenFile = join(tokensDir, 'helper.token')
|
|
235
|
+
const current = existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined
|
|
236
|
+
if (current === token)
|
|
237
|
+
rmSync(tokenFile, { force: true })
|
|
238
|
+
}
|
|
239
|
+
catch {}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
rmSync(socketPath, { force: true })
|
|
243
|
+
}
|
|
244
|
+
catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
166
247
|
/** Sockets left behind by a killed helper cannot be reused; remove them first. */
|
|
167
248
|
export function removeStaleSocket(socketPath: string): void {
|
|
168
249
|
if (process.platform === 'win32')
|
package/src/privileged/entry.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
3
|
import process from 'node:process'
|
|
4
4
|
|
|
5
5
|
export interface HelperEntry {
|
|
@@ -26,20 +26,48 @@ export interface ResolveHelperEntryOptions {
|
|
|
26
26
|
* Published builds ship a compiled `privileged-helper.js` and start it with
|
|
27
27
|
* node; running from the repository uses `tsx` so the TypeScript source works
|
|
28
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.
|
|
29
33
|
*/
|
|
30
34
|
export function resolveHelperEntry(options: ResolveHelperEntryOptions): HelperEntry {
|
|
31
35
|
const { moduleDir, nodeBin = process.execPath, exists = existsSync } = options
|
|
32
36
|
|
|
33
37
|
for (const name of ['privileged-helper.js', 'privileged-helper.cjs', 'privileged-helper.mjs']) {
|
|
34
|
-
const candidate =
|
|
35
|
-
if (
|
|
38
|
+
const candidate = findUp(moduleDir, name, exists)
|
|
39
|
+
if (candidate)
|
|
36
40
|
return { entry: nodeBin, args: [candidate] }
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
const source =
|
|
40
|
-
const tsxCli =
|
|
41
|
-
if (
|
|
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)
|
|
42
46
|
return { entry: nodeBin, args: [tsxCli, source] }
|
|
43
47
|
|
|
44
48
|
throw new Error(`privileged helper entry not found in ${moduleDir}. Build the package first.`)
|
|
45
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
|
+
}
|
package/src/privileged/launch.ts
CHANGED
|
@@ -39,6 +39,25 @@ export interface LaunchOptions {
|
|
|
39
39
|
caddyBin: string
|
|
40
40
|
/** Caddyfile the helper is allowed to load. */
|
|
41
41
|
caddyConfigPath: string
|
|
42
|
+
/** Shown before the password prompt so the request is not a surprise. */
|
|
43
|
+
reason?: { target: string, https: boolean }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Explains why a password is about to be requested. Without this the prompt
|
|
48
|
+
* appears out of nowhere in the middle of `npm run dev` and looks like something
|
|
49
|
+
* went wrong.
|
|
50
|
+
*/
|
|
51
|
+
export function describePasswordReason(target: string, https: boolean): string[] {
|
|
52
|
+
const port = https ? '443' : '80'
|
|
53
|
+
return [
|
|
54
|
+
`reverse proxy needs administrator permission for ${target}`,
|
|
55
|
+
` · bind port ${port}${https ? ` for https://${target}` : ''}`,
|
|
56
|
+
` · add ${target} to the hosts file`,
|
|
57
|
+
'',
|
|
58
|
+
'This is the only prompt; the rest of the dev server keeps running as you.',
|
|
59
|
+
'Press Ctrl+C at the prompt to skip the proxy and keep the dev server.',
|
|
60
|
+
]
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
/**
|
|
@@ -47,23 +66,44 @@ export interface LaunchOptions {
|
|
|
47
66
|
* The credential cache is keyed by terminal, so a later `sudo -n` only finds
|
|
48
67
|
* this entry when the child stays in the same session. That is why the helper
|
|
49
68
|
* is spawned without `detached`.
|
|
69
|
+
*
|
|
70
|
+
* Returns `denied` when the user dismissed the prompt, so the caller can keep
|
|
71
|
+
* the dev server running instead of treating it as a crash.
|
|
50
72
|
*/
|
|
51
|
-
export function authenticateSudo(
|
|
73
|
+
export function authenticateSudo(
|
|
74
|
+
sudoCommand = 'sudo',
|
|
75
|
+
options: { target?: string, https?: boolean } = {},
|
|
76
|
+
): { ok: boolean, denied?: boolean, message?: string } {
|
|
52
77
|
if (platform() === 'win32')
|
|
53
78
|
return { ok: true }
|
|
54
79
|
|
|
55
80
|
if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0)
|
|
56
81
|
return { ok: true }
|
|
57
82
|
|
|
83
|
+
if (options.target) {
|
|
84
|
+
for (const line of describePasswordReason(options.target, options.https ?? false))
|
|
85
|
+
process.stderr.write(`${line}\n`)
|
|
86
|
+
process.stderr.write('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
58
89
|
if (spawnSync(sudoCommand, ['-v'], { stdio: 'inherit' }).status === 0)
|
|
59
90
|
return { ok: true }
|
|
60
91
|
|
|
61
92
|
return {
|
|
62
93
|
ok: false,
|
|
94
|
+
denied: true,
|
|
63
95
|
message: 'administrator permission is required to edit the hosts file and bind ports 80/443',
|
|
64
96
|
}
|
|
65
97
|
}
|
|
66
98
|
|
|
99
|
+
/** The user dismissed the password prompt; this is a choice, not a failure. */
|
|
100
|
+
export class PrivilegeDeniedError extends Error {
|
|
101
|
+
constructor(message: string) {
|
|
102
|
+
super(message)
|
|
103
|
+
this.name = 'PrivilegeDeniedError'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
67
107
|
export interface PrivilegedHelperHandle {
|
|
68
108
|
client: PrivilegedClient
|
|
69
109
|
close: () => Promise<void>
|
|
@@ -92,9 +132,11 @@ export async function startPrivilegedHelper(options: LaunchOptions): Promise<Pri
|
|
|
92
132
|
} = options
|
|
93
133
|
|
|
94
134
|
if (!skipAuth && !direct) {
|
|
95
|
-
const auth = authenticateSudo(sudoCommand)
|
|
96
|
-
if (!auth.ok)
|
|
97
|
-
|
|
135
|
+
const auth = authenticateSudo(sudoCommand, options.reason)
|
|
136
|
+
if (!auth.ok) {
|
|
137
|
+
const message = auth.message ?? 'administrator permission is required'
|
|
138
|
+
throw auth.denied ? new PrivilegeDeniedError(message) : new Error(message)
|
|
139
|
+
}
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
const forwarded = collectForwardedEnv(env)
|
package/src/privileged/server.ts
CHANGED
|
@@ -46,6 +46,8 @@ const FLUSH_FALLBACK = [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }] as
|
|
|
46
46
|
export interface CaddyChild {
|
|
47
47
|
pid?: number
|
|
48
48
|
kill: (signal?: NodeJS.Signals) => boolean
|
|
49
|
+
/** False once the process has exited; undefined when unknown. */
|
|
50
|
+
running?: boolean
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -60,7 +62,8 @@ export interface HelperDeps {
|
|
|
60
62
|
readHosts: () => string
|
|
61
63
|
writeHosts: (content: string) => void
|
|
62
64
|
flushDns: () => Promise<void>
|
|
63
|
-
|
|
65
|
+
/** Releases a port; never kills the pid in `keepPid`. */
|
|
66
|
+
freePort: (port: number, keepPid?: number) => Promise<number[]>
|
|
64
67
|
/** Caddy binary and the Caddyfile it must load, both fixed at start-up. */
|
|
65
68
|
caddyBin: string
|
|
66
69
|
caddyConfigPath: string
|
|
@@ -91,7 +94,8 @@ async function flushDnsBestEffort(deps: HelperDeps): Promise<void> {
|
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
export interface HelperRuntime {
|
|
94
|
-
|
|
97
|
+
/** `client` identifies the caller so shared host mappings are reference-counted. */
|
|
98
|
+
handle: (request: HelperRequest, client?: object) => Promise<HelperResponse>
|
|
95
99
|
/** Registers a connected client so Caddy output reaches it. */
|
|
96
100
|
attach: (client: object) => void
|
|
97
101
|
/** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */
|
|
@@ -114,6 +118,37 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
114
118
|
let generation = 0
|
|
115
119
|
let caddy: CaddyChild | undefined
|
|
116
120
|
|
|
121
|
+
// Which client asked for which host. Two projects may map the same domain, so
|
|
122
|
+
// a mapping is only removed once its last holder is gone.
|
|
123
|
+
const hostOwners = new Map<string, Set<object>>()
|
|
124
|
+
|
|
125
|
+
const hostId = (ip: string, host: string): string => `${ip} ${host.toLowerCase()}`
|
|
126
|
+
|
|
127
|
+
function addHostOwner(client: object | undefined, ip: string, host: string): void {
|
|
128
|
+
const id = hostId(ip, host)
|
|
129
|
+
let owners = hostOwners.get(id)
|
|
130
|
+
if (!owners) {
|
|
131
|
+
owners = new Set()
|
|
132
|
+
hostOwners.set(id, owners)
|
|
133
|
+
}
|
|
134
|
+
owners.add(client ?? deps)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True when no other client still needs this mapping. */
|
|
138
|
+
function releaseHostOwner(client: object | undefined, ip: string, host: string): boolean {
|
|
139
|
+
const id = hostId(ip, host)
|
|
140
|
+
const owners = hostOwners.get(id)
|
|
141
|
+
if (!owners)
|
|
142
|
+
return true
|
|
143
|
+
|
|
144
|
+
owners.delete(client ?? deps)
|
|
145
|
+
if (owners.size > 0)
|
|
146
|
+
return false
|
|
147
|
+
|
|
148
|
+
hostOwners.delete(id)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
117
152
|
const broadcast = (event: HelperEvent): void => {
|
|
118
153
|
const line = serializeMessage(event)
|
|
119
154
|
for (const client of clients) {
|
|
@@ -183,7 +218,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
183
218
|
}
|
|
184
219
|
}
|
|
185
220
|
|
|
186
|
-
async function handle(request: HelperRequest): Promise<HelperResponse> {
|
|
221
|
+
async function handle(request: HelperRequest, client?: object): Promise<HelperResponse> {
|
|
187
222
|
try {
|
|
188
223
|
switch (request.method) {
|
|
189
224
|
case 'ping':
|
|
@@ -197,6 +232,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
197
232
|
const edited = addHost(deps.readHosts(), ip, host)
|
|
198
233
|
if (edited.changed)
|
|
199
234
|
deps.writeHosts(edited.content)
|
|
235
|
+
addHostOwner(client, ip, host)
|
|
200
236
|
await flushDnsBestEffort(deps)
|
|
201
237
|
return { id: request.id, ok: true }
|
|
202
238
|
}
|
|
@@ -206,6 +242,12 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
206
242
|
if (!isSafeIp(ip) || !isSafeHost(host))
|
|
207
243
|
return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
|
|
208
244
|
|
|
245
|
+
// Another project may still need this domain; only the last holder
|
|
246
|
+
// removes the mapping.
|
|
247
|
+
const last = releaseHostOwner(client, ip, host)
|
|
248
|
+
if (!last)
|
|
249
|
+
return { id: request.id, ok: true, result: { kept: true } }
|
|
250
|
+
|
|
209
251
|
const edited = removeHost(deps.readHosts(), ip, host)
|
|
210
252
|
if (edited.changed)
|
|
211
253
|
deps.writeHosts(edited.content)
|
|
@@ -218,16 +260,20 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
218
260
|
if (!isAllowedFreePort(port))
|
|
219
261
|
return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } }
|
|
220
262
|
|
|
221
|
-
|
|
263
|
+
// Caddy belongs to this helper, so a port sweep must never kill it:
|
|
264
|
+
// the pid is the one serving every other project's domains.
|
|
265
|
+
const killed = await deps.freePort(port, caddy?.pid)
|
|
222
266
|
return { id: request.id, ok: true, result: { killed } }
|
|
223
267
|
}
|
|
224
268
|
|
|
225
269
|
case 'caddy.start': {
|
|
226
|
-
|
|
227
|
-
|
|
270
|
+
// Idempotent: several dev servers share one Caddy, so a second start
|
|
271
|
+
// must attach to the running process instead of restarting it.
|
|
272
|
+
if (caddy && caddy.running !== false)
|
|
273
|
+
return { id: request.id, ok: true, result: { pid: caddy.pid, started: false } }
|
|
228
274
|
|
|
229
275
|
caddy = deps.startCaddy(broadcast)
|
|
230
|
-
return { id: request.id, ok: true, result: { pid: caddy.pid } }
|
|
276
|
+
return { id: request.id, ok: true, result: { pid: caddy.pid, started: true } }
|
|
231
277
|
}
|
|
232
278
|
|
|
233
279
|
case 'caddy.stop': {
|
|
@@ -260,6 +306,27 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
260
306
|
},
|
|
261
307
|
async detach(client) {
|
|
262
308
|
clients.delete(client)
|
|
309
|
+
|
|
310
|
+
// Drop only the mappings this client was the last holder of, so a project
|
|
311
|
+
// that exits leaves the domains another project still serves alone.
|
|
312
|
+
for (const [id, owners] of [...hostOwners]) {
|
|
313
|
+
if (!owners.delete(client) || owners.size > 0)
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
hostOwners.delete(id)
|
|
317
|
+
const [ip, host] = id.split(' ')
|
|
318
|
+
try {
|
|
319
|
+
const edited = removeHost(deps.readHosts(), ip, host)
|
|
320
|
+
if (edited.changed) {
|
|
321
|
+
deps.writeHosts(edited.content)
|
|
322
|
+
await flushDnsBestEffort(deps)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
deps.log(`hosts cleanup failed: ${(error as Error).message}`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
263
330
|
if (clients.size === 0)
|
|
264
331
|
await dispose()
|
|
265
332
|
},
|
|
@@ -367,7 +434,7 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
|
|
|
367
434
|
|
|
368
435
|
throw lastError ?? new Error('no DNS cache flush command for this platform')
|
|
369
436
|
},
|
|
370
|
-
freePort: async (port) => {
|
|
437
|
+
freePort: async (port, keepPid) => {
|
|
371
438
|
// `lsof` exits with 1 when nothing matches, which is the normal case for
|
|
372
439
|
// a free port; only its stdout carries the answer.
|
|
373
440
|
const pids = platform() === 'win32'
|
|
@@ -376,7 +443,9 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
|
|
|
376
443
|
|
|
377
444
|
const killed: number[] = []
|
|
378
445
|
for (const pid of pids) {
|
|
379
|
-
|
|
446
|
+
// Never kill the helper itself or the Caddy it owns: that Caddy serves
|
|
447
|
+
// every project sharing this helper, not just the caller's.
|
|
448
|
+
if (pid === process.pid || (keepPid !== undefined && pid === keepPid))
|
|
380
449
|
continue
|
|
381
450
|
try {
|
|
382
451
|
process.kill(pid, 'SIGTERM')
|
|
@@ -394,16 +463,28 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
|
|
|
394
463
|
caddyStoragePath: options.caddyStoragePath,
|
|
395
464
|
storageOwner: options.storageOwner,
|
|
396
465
|
startCaddy: (emit) => {
|
|
397
|
-
|
|
466
|
+
// `--watch` makes Caddy reload when another project rewrites the shared
|
|
467
|
+
// Caddyfile. Without it the second project's route never takes effect.
|
|
468
|
+
const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath, '--watch'], {
|
|
398
469
|
cwd: options.caddyCwd ?? process.cwd(),
|
|
399
470
|
env: { ...process.env, ...options.caddyEnv },
|
|
400
471
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
401
472
|
})
|
|
473
|
+
const handle: CaddyChild & { running?: boolean } = {
|
|
474
|
+
pid: child.pid,
|
|
475
|
+
kill: signal => child.kill(signal),
|
|
476
|
+
running: true,
|
|
477
|
+
}
|
|
402
478
|
child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() }))
|
|
403
479
|
child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() }))
|
|
404
|
-
child.on('exit', (code, signal) =>
|
|
405
|
-
|
|
406
|
-
|
|
480
|
+
child.on('exit', (code, signal) => {
|
|
481
|
+
// Recorded before broadcasting so a later `caddy.start` knows it has to
|
|
482
|
+
// spawn again rather than reuse a dead process.
|
|
483
|
+
handle.running = false
|
|
484
|
+
emit({ type: 'event', kind: 'caddy.exit', code, signal })
|
|
485
|
+
})
|
|
486
|
+
caddy = handle
|
|
487
|
+
return handle
|
|
407
488
|
},
|
|
408
489
|
stopCaddy: async () => {
|
|
409
490
|
const current = caddy
|
|
@@ -448,7 +529,7 @@ export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (messa
|
|
|
448
529
|
continue
|
|
449
530
|
}
|
|
450
531
|
|
|
451
|
-
void runtime.handle(request).then((response) => {
|
|
532
|
+
void runtime.handle(request, socket).then((response) => {
|
|
452
533
|
if (!socket.destroyed)
|
|
453
534
|
socket.write(serializeMessage(response))
|
|
454
535
|
})
|