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,332 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
2
|
+
import type { PrivilegedClient } from './client'
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import { platform } from 'node:os'
|
|
6
|
+
import process from 'node:process'
|
|
7
|
+
import { collectForwardedEnv, connectClient } from './client'
|
|
8
|
+
import { spawnElevatedOnWindows } from './windows-elevation'
|
|
9
|
+
|
|
10
|
+
export function createHelperToken(): string {
|
|
11
|
+
return randomBytes(24).toString('hex')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LaunchOptions {
|
|
15
|
+
socketPath: string
|
|
16
|
+
token: string
|
|
17
|
+
/** Executable started as root (node, or tsx when running from source). */
|
|
18
|
+
helperEntry: string
|
|
19
|
+
/** Arguments placed before the helper's own `--socket/--token` flags. */
|
|
20
|
+
helperArgs?: string[]
|
|
21
|
+
sudoCommand?: string
|
|
22
|
+
/** Skips the interactive credential check; used by tests and Windows. */
|
|
23
|
+
skipAuth?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Starts the helper as the current user instead of going through sudo.
|
|
26
|
+
* Used by tests that need the helper protocol but not real privileges.
|
|
27
|
+
*/
|
|
28
|
+
direct?: boolean
|
|
29
|
+
env?: NodeJS.ProcessEnv
|
|
30
|
+
log?: (message: string) => void
|
|
31
|
+
/** How long to wait for the helper socket to accept the connection. */
|
|
32
|
+
connectTimeoutMs?: number
|
|
33
|
+
/**
|
|
34
|
+
* Caddy's storage directory. Caddy runs as root in the helper, so the helper
|
|
35
|
+
* hands this tree back to the developer when it stops Caddy.
|
|
36
|
+
*/
|
|
37
|
+
caddyStoragePath?: string
|
|
38
|
+
/** Caddy binary the helper is allowed to run, fixed for the whole session. */
|
|
39
|
+
caddyBin: string
|
|
40
|
+
/** Caddyfile the helper is allowed to load. */
|
|
41
|
+
caddyConfigPath: string
|
|
42
|
+
/** Admin API socket the helper talks to when installing the CA. */
|
|
43
|
+
caddyAdminSocket?: string
|
|
44
|
+
/** Shown before the password prompt so the request is not a surprise. */
|
|
45
|
+
reason?: { target: string, https: boolean }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Explains why a password is about to be requested. Without this the prompt
|
|
50
|
+
* appears out of nowhere in the middle of `npm run dev` and looks like something
|
|
51
|
+
* went wrong.
|
|
52
|
+
*/
|
|
53
|
+
export function describePasswordReason(target: string, https: boolean): string[] {
|
|
54
|
+
const port = https ? '443' : '80'
|
|
55
|
+
return [
|
|
56
|
+
`reverse proxy needs administrator permission for ${target}`,
|
|
57
|
+
` · bind port ${port}${https ? ` for https://${target}` : ''}`,
|
|
58
|
+
` · add ${target} to the hosts file`,
|
|
59
|
+
'',
|
|
60
|
+
'This is the only prompt; the rest of the dev server keeps running as you.',
|
|
61
|
+
'Press Ctrl+C at the prompt to skip the proxy and keep the dev server.',
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Asks for the administrator password once, up front, on the dev terminal.
|
|
67
|
+
*
|
|
68
|
+
* The credential cache is keyed by terminal, so a later `sudo -n` only finds
|
|
69
|
+
* this entry when the child stays in the same session. That is why the helper
|
|
70
|
+
* is spawned without `detached`.
|
|
71
|
+
*
|
|
72
|
+
* Returns `denied` when the user dismissed the prompt, so the caller can keep
|
|
73
|
+
* the dev server running instead of treating it as a crash.
|
|
74
|
+
*/
|
|
75
|
+
export async function authenticateSudo(
|
|
76
|
+
sudoCommand = 'sudo',
|
|
77
|
+
options: { target?: string, https?: boolean, quiet?: boolean } = {},
|
|
78
|
+
): Promise<{ ok: boolean, denied?: boolean, message?: string }> {
|
|
79
|
+
if (platform() === 'win32')
|
|
80
|
+
return { ok: true }
|
|
81
|
+
|
|
82
|
+
if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0)
|
|
83
|
+
return { ok: true }
|
|
84
|
+
|
|
85
|
+
// Tests drive this without a terminal; writing the notice there would print
|
|
86
|
+
// password instructions into the middle of the test output.
|
|
87
|
+
if (options.target && !options.quiet) {
|
|
88
|
+
for (const line of describePasswordReason(options.target, options.https ?? false))
|
|
89
|
+
process.stderr.write(`${line}\n`)
|
|
90
|
+
process.stderr.write('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// `sudo -v` inherits the terminal, so Ctrl+C reaches this process too. Without
|
|
94
|
+
// a handler the default action kills the dev server, which reads as "the
|
|
95
|
+
// proxy crashed my dev server" when the user only meant to skip it.
|
|
96
|
+
let interrupted = false
|
|
97
|
+
const onSigint = (): void => {
|
|
98
|
+
interrupted = true
|
|
99
|
+
}
|
|
100
|
+
process.on('SIGINT', onSigint)
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const { status, signal } = await runPrompt(sudoCommand)
|
|
104
|
+
if (status === 0)
|
|
105
|
+
return { ok: true }
|
|
106
|
+
|
|
107
|
+
// `sudo` exits 1 on a wrong password and dies by the signal on Ctrl+C.
|
|
108
|
+
// Both are checked: a real Ctrl+C reaches this process and the child
|
|
109
|
+
// together, while a signal aimed at the child alone only shows up here.
|
|
110
|
+
const cancelled = interrupted || signal === 'SIGINT'
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
denied: true,
|
|
114
|
+
message: cancelled
|
|
115
|
+
? 'administrator permission was cancelled'
|
|
116
|
+
: 'administrator permission is required to edit the hosts file and bind ports 80/443',
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
process.off('SIGINT', onSigint)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Runs `sudo -v` with the terminal attached.
|
|
126
|
+
*
|
|
127
|
+
* Spawned asynchronously rather than with `spawnSync`: the synchronous form
|
|
128
|
+
* blocks the event loop, so the SIGINT handler above never runs and the exit
|
|
129
|
+
* looks like a plain failure instead of a cancellation.
|
|
130
|
+
*
|
|
131
|
+
* The terminal is put back into cooked mode first. Vite turns on raw mode for
|
|
132
|
+
* its `h + enter` shortcuts, and in that mode Enter no longer terminates the
|
|
133
|
+
* line sudo is reading, so the password appears to need a second Enter.
|
|
134
|
+
*/
|
|
135
|
+
function runPrompt(sudoCommand: string): Promise<{ status: number | null, signal: NodeJS.Signals | null }> {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const restore = takeBackTerminal()
|
|
138
|
+
const child = spawn(sudoCommand, ['-v'], { stdio: 'inherit' })
|
|
139
|
+
|
|
140
|
+
const finish = (result: { status: number | null, signal: NodeJS.Signals | null }): void => {
|
|
141
|
+
restore()
|
|
142
|
+
resolve(result)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
child.on('error', () => finish({ status: 1, signal: null }))
|
|
146
|
+
child.on('exit', (status, signal) => finish({ status, signal }))
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Gives the terminal to `sudo` for the duration of the prompt.
|
|
152
|
+
*
|
|
153
|
+
* Vite keeps stdin in raw mode with a `data` listener attached, so that every
|
|
154
|
+
* keystroke reaches its shortcut handler. Two things break the password read:
|
|
155
|
+
*
|
|
156
|
+
* 1. Raw mode does no line editing, so Enter never terminates the line sudo is
|
|
157
|
+
* waiting for.
|
|
158
|
+
* 2. The `data` listener is resumed, so it consumes the characters before sudo
|
|
159
|
+
* can see them. sudo then reads nothing and reports a wrong password.
|
|
160
|
+
*
|
|
161
|
+
* Both are undone here and restored afterwards, so Vite's shortcuts keep
|
|
162
|
+
* working once the prompt is over.
|
|
163
|
+
*/
|
|
164
|
+
function takeBackTerminal(): () => void {
|
|
165
|
+
const stdin = process.stdin
|
|
166
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function')
|
|
167
|
+
return () => {}
|
|
168
|
+
|
|
169
|
+
const wasRaw = stdin.isRaw === true
|
|
170
|
+
|
|
171
|
+
// `pause` detaches the stream from the tty without removing Vite's listener,
|
|
172
|
+
// so the same handler still works when the stream is resumed later.
|
|
173
|
+
stdin.pause()
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
stdin.setRawMode(false)
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
stdin.resume()
|
|
180
|
+
return () => {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return () => {
|
|
184
|
+
if (wasRaw) {
|
|
185
|
+
try {
|
|
186
|
+
stdin.setRawMode(true)
|
|
187
|
+
}
|
|
188
|
+
catch {}
|
|
189
|
+
}
|
|
190
|
+
// Resume last: Vite's listener is still attached and expects a live stream.
|
|
191
|
+
stdin.resume()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The user dismissed the password prompt; this is a choice, not a failure. */
|
|
196
|
+
export class PrivilegeDeniedError extends Error {
|
|
197
|
+
constructor(message: string) {
|
|
198
|
+
super(message)
|
|
199
|
+
this.name = 'PrivilegeDeniedError'
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface PrivilegedHelperHandle {
|
|
204
|
+
client: PrivilegedClient
|
|
205
|
+
close: () => Promise<void>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Starts the privileged helper and connects to it.
|
|
210
|
+
*
|
|
211
|
+
* Only this child runs as root: the Node process that owns the dev server,
|
|
212
|
+
* its caches and `node_modules` keeps the developer's identity. The user's
|
|
213
|
+
* proxy/HOME settings reach Caddy through the helper's environment instead of
|
|
214
|
+
* `sudo -E`.
|
|
215
|
+
*/
|
|
216
|
+
export async function startPrivilegedHelper(options: LaunchOptions): Promise<PrivilegedHelperHandle> {
|
|
217
|
+
const {
|
|
218
|
+
socketPath,
|
|
219
|
+
token,
|
|
220
|
+
helperEntry,
|
|
221
|
+
helperArgs = [],
|
|
222
|
+
sudoCommand = 'sudo',
|
|
223
|
+
skipAuth = false,
|
|
224
|
+
direct = false,
|
|
225
|
+
env = process.env,
|
|
226
|
+
log,
|
|
227
|
+
connectTimeoutMs = 120_000,
|
|
228
|
+
} = options
|
|
229
|
+
|
|
230
|
+
if (!skipAuth && !direct) {
|
|
231
|
+
const auth = await authenticateSudo(sudoCommand, options.reason)
|
|
232
|
+
if (!auth.ok) {
|
|
233
|
+
const message = auth.message ?? 'administrator permission is required'
|
|
234
|
+
throw auth.denied ? new PrivilegeDeniedError(message) : new Error(message)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const forwarded = collectForwardedEnv(env)
|
|
239
|
+
const forwardedArgs = Object.entries(forwarded).map(([key, value]) => `${key}=${value}`)
|
|
240
|
+
// `helperEntry` is the runtime and `helperArgs` the script it must run, so
|
|
241
|
+
// the helper's own flags always come last. The owner ids travel with them
|
|
242
|
+
// because the helper only learns who its client is at handshake time.
|
|
243
|
+
const childArgv = [
|
|
244
|
+
...helperArgs,
|
|
245
|
+
'--socket',
|
|
246
|
+
socketPath,
|
|
247
|
+
'--token',
|
|
248
|
+
token,
|
|
249
|
+
...(typeof process.getuid === 'function' ? ['--owner-uid', String(process.getuid())] : []),
|
|
250
|
+
...(typeof process.getgid === 'function' ? ['--owner-gid', String(process.getgid())] : []),
|
|
251
|
+
...(options.caddyStoragePath ? ['--caddy-storage', options.caddyStoragePath] : []),
|
|
252
|
+
'--caddy',
|
|
253
|
+
options.caddyBin,
|
|
254
|
+
'--caddy-config',
|
|
255
|
+
options.caddyConfigPath,
|
|
256
|
+
...(options.caddyAdminSocket ? ['--caddy-admin', options.caddyAdminSocket] : []),
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
let child: ReturnType<typeof spawn> | undefined
|
|
260
|
+
|
|
261
|
+
if (!direct && platform() === 'win32') {
|
|
262
|
+
// UAC: the elevated helper is a separate process and cannot inherit stdio,
|
|
263
|
+
// so it reports through the named pipe instead. The user's environment is
|
|
264
|
+
// recovered by the helper from its own process env, which Windows keeps.
|
|
265
|
+
await spawnElevatedOnWindows({ file: helperEntry, args: childArgv, cwd: process.cwd() })
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const command = direct ? helperEntry : sudoCommand
|
|
269
|
+
const spawnArgs = direct
|
|
270
|
+
? childArgv
|
|
271
|
+
: ['-n', 'env', ...forwardedArgs, helperEntry, ...childArgv]
|
|
272
|
+
|
|
273
|
+
// No `detached`: sudo stores its credential per session, and `setsid()`
|
|
274
|
+
// would make the helper look like a different login, so `sudo -n` could not
|
|
275
|
+
// reuse the password entered a moment earlier. The stdio pipes keep the
|
|
276
|
+
// helper reachable from this process.
|
|
277
|
+
child = spawn(command, spawnArgs, {
|
|
278
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
child.stdout?.on('data', (data: Buffer) => log?.(data.toString().trimEnd()))
|
|
282
|
+
child.stderr?.on('data', (data: Buffer) => log?.(data.toString().trimEnd()))
|
|
283
|
+
|
|
284
|
+
const exited = new Promise<number | null>(resolve => child!.once('exit', code => resolve(code)))
|
|
285
|
+
|
|
286
|
+
// Refused credentials make the helper exit immediately; report that instead
|
|
287
|
+
// of waiting for the full connection timeout.
|
|
288
|
+
const earlyExit = await Promise.race([
|
|
289
|
+
exited,
|
|
290
|
+
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 500)),
|
|
291
|
+
])
|
|
292
|
+
|
|
293
|
+
if (earlyExit !== undefined && earlyExit !== 0) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`privileged helper exited with code ${earlyExit}. `
|
|
296
|
+
+ 'Administrator permission was refused, or sudo is not available.',
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const client = await connectClient({ socketPath, token, timeoutMs: connectTimeoutMs })
|
|
303
|
+
return {
|
|
304
|
+
client,
|
|
305
|
+
async close() {
|
|
306
|
+
await client.close().catch(() => {})
|
|
307
|
+
child?.unref()
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
try {
|
|
313
|
+
child?.kill()
|
|
314
|
+
}
|
|
315
|
+
catch {}
|
|
316
|
+
throw error
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Reuses a helper that is already running for this runtime directory. */
|
|
321
|
+
export async function connectExistingHelper(
|
|
322
|
+
socketPath: string,
|
|
323
|
+
token: string,
|
|
324
|
+
timeoutMs = 1500,
|
|
325
|
+
): Promise<PrivilegedClient | undefined> {
|
|
326
|
+
try {
|
|
327
|
+
return await connectClient({ socketPath, token, timeoutMs })
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return undefined
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Stats } from 'node:fs'
|
|
2
|
+
import { lstatSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Why a path is refused before the helper runs it.
|
|
6
|
+
*
|
|
7
|
+
* The helper runs as root while the paths come from a process running as the
|
|
8
|
+
* developer. Anything that process, or any other local user, can rewrite after
|
|
9
|
+
* the check would become root-executed code or root-loaded config, so the
|
|
10
|
+
* checks below run once at startup and the result is trusted for the session.
|
|
11
|
+
*/
|
|
12
|
+
export interface PathRejection {
|
|
13
|
+
path: string
|
|
14
|
+
reason: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PathCheckOptions {
|
|
18
|
+
/**
|
|
19
|
+
* uid allowed to own the path besides root.
|
|
20
|
+
*
|
|
21
|
+
* The helper entry itself lives in the developer's `node_modules`, so the
|
|
22
|
+
* invoking user has to be accepted for the paths that came from there.
|
|
23
|
+
*/
|
|
24
|
+
ownerUid?: number
|
|
25
|
+
/** Human-readable role, used in the rejection message. */
|
|
26
|
+
label: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function describe(stats: Stats): string {
|
|
30
|
+
return `uid=${stats.uid} mode=${(stats.mode & 0o777).toString(8)}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rejects a path the helper must not execute or load.
|
|
35
|
+
*
|
|
36
|
+
* Symbolic links are refused outright: the check has to describe the file that
|
|
37
|
+
* will actually be used, and a link can be repointed after the check. Group- or
|
|
38
|
+
* world-writable files are refused because any local user could replace them
|
|
39
|
+
* between the check and the exec.
|
|
40
|
+
*/
|
|
41
|
+
export function checkPrivilegedPath(
|
|
42
|
+
path: string,
|
|
43
|
+
options: PathCheckOptions,
|
|
44
|
+
): PathRejection | undefined {
|
|
45
|
+
let stats: Stats
|
|
46
|
+
try {
|
|
47
|
+
stats = lstatSync(path)
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { path, reason: `${options.label} is not readable: ${(error as Error).message}` }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (stats.isSymbolicLink())
|
|
54
|
+
return { path, reason: `${options.label} is a symbolic link (${describe(stats)})` }
|
|
55
|
+
|
|
56
|
+
if (!stats.isFile() && !stats.isDirectory())
|
|
57
|
+
return { path, reason: `${options.label} is neither a file nor a directory` }
|
|
58
|
+
|
|
59
|
+
const allowedOwner = stats.uid === 0 || (options.ownerUid !== undefined && stats.uid === options.ownerUid)
|
|
60
|
+
if (!allowedOwner)
|
|
61
|
+
return { path, reason: `${options.label} is owned by another user (${describe(stats)})` }
|
|
62
|
+
|
|
63
|
+
// Root's own files are fine at 644/755; the point is that no one else can
|
|
64
|
+
// write them.
|
|
65
|
+
if (stats.mode & 0o022)
|
|
66
|
+
return { path, reason: `${options.label} is group- or world-writable (${describe(stats)})` }
|
|
67
|
+
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Formats a rejection for the helper log; one place keeps the wording stable. */
|
|
72
|
+
export function describeRejection(rejection: PathRejection): string {
|
|
73
|
+
return `refusing to start: ${rejection.reason} (${rejection.path})`
|
|
74
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for freeing a listening TCP port without shelling out to `npx`.
|
|
3
|
+
* `parseListeningPids` keeps the decision testable: it only turns command
|
|
4
|
+
* output into pids, never spawns anything.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Parses `lsof -ti:<port> -sTCP:LISTEN` output (one pid per line). */
|
|
8
|
+
export function parseListeningPids(output: string): number[] {
|
|
9
|
+
const pids = new Set<number>()
|
|
10
|
+
for (const line of output.split(/\r?\n/)) {
|
|
11
|
+
const trimmed = line.trim()
|
|
12
|
+
if (!trimmed)
|
|
13
|
+
continue
|
|
14
|
+
if (!/^\d+$/.test(trimmed))
|
|
15
|
+
continue
|
|
16
|
+
const pid = Number(trimmed)
|
|
17
|
+
if (Number.isSafeInteger(pid) && pid > 0)
|
|
18
|
+
pids.add(pid)
|
|
19
|
+
}
|
|
20
|
+
return [...pids]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parses `netstat -ano` output for the owning pids of a listening port on Windows. */
|
|
24
|
+
export function parseWindowsListeningPids(output: string, port: number): number[] {
|
|
25
|
+
const pids = new Set<number>()
|
|
26
|
+
const suffix = `:${port}`
|
|
27
|
+
|
|
28
|
+
for (const line of output.split(/\r?\n/)) {
|
|
29
|
+
const columns = line.trim().split(/\s+/)
|
|
30
|
+
if (columns.length < 5)
|
|
31
|
+
continue
|
|
32
|
+
const protocol = columns[0].toUpperCase()
|
|
33
|
+
if (protocol !== 'TCP')
|
|
34
|
+
continue
|
|
35
|
+
if (!columns[1].endsWith(suffix))
|
|
36
|
+
continue
|
|
37
|
+
if (columns[3].toUpperCase() !== 'LISTENING')
|
|
38
|
+
continue
|
|
39
|
+
const pid = Number(columns[4])
|
|
40
|
+
if (Number.isSafeInteger(pid) && pid > 0)
|
|
41
|
+
pids.add(pid)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [...pids]
|
|
45
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol between the unprivileged plugin process and the privileged
|
|
3
|
+
* helper. Newline-delimited JSON keeps the helper dependency-free.
|
|
4
|
+
*
|
|
5
|
+
* The helper never executes a binary or uses a path supplied by the client:
|
|
6
|
+
* both are fixed when the helper starts (`--caddy` and `--caddy-config`), so a
|
|
7
|
+
* client that reaches the socket cannot turn the helper into a root shell.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type RpcId = number
|
|
11
|
+
|
|
12
|
+
export interface RpcErrorPayload {
|
|
13
|
+
message: string
|
|
14
|
+
code?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type HelperRequest =
|
|
18
|
+
| { id: RpcId, method: 'ping' }
|
|
19
|
+
| { id: RpcId, method: 'hosts.add', params: { ip: string, host: string } }
|
|
20
|
+
| { id: RpcId, method: 'hosts.remove', params: { ip: string, host: string } }
|
|
21
|
+
| { id: RpcId, method: 'port.free', params: { port: number } }
|
|
22
|
+
| { id: RpcId, method: 'caddy.start' }
|
|
23
|
+
| { id: RpcId, method: 'caddy.stop' }
|
|
24
|
+
| { id: RpcId, method: 'ca.trust' }
|
|
25
|
+
|
|
26
|
+
/** Request method names, used by the client's typed `call` overloads. */
|
|
27
|
+
export type HelperMethod = HelperRequest['method']
|
|
28
|
+
|
|
29
|
+
export type HelperResponse =
|
|
30
|
+
| { id: RpcId, ok: true, result?: unknown }
|
|
31
|
+
| { id: RpcId, ok: false, error: RpcErrorPayload }
|
|
32
|
+
|
|
33
|
+
export type HelperEvent =
|
|
34
|
+
| { type: 'event', kind: 'caddy.stdout', data: string }
|
|
35
|
+
| { type: 'event', kind: 'caddy.stderr', data: string }
|
|
36
|
+
| { type: 'event', kind: 'caddy.exit', code: number | null, signal: string | null }
|
|
37
|
+
|
|
38
|
+
export type HelperMessage = HelperResponse | HelperEvent
|
|
39
|
+
|
|
40
|
+
export interface ClientHandshake {
|
|
41
|
+
type: 'handshake'
|
|
42
|
+
token: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ServerHandshake {
|
|
46
|
+
type: 'handshake'
|
|
47
|
+
ok: boolean
|
|
48
|
+
pid?: number
|
|
49
|
+
reason?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type DecodedMessage =
|
|
53
|
+
| { ok: true, message: HelperMessage }
|
|
54
|
+
| { ok: false, error: string }
|
|
55
|
+
|
|
56
|
+
export function serializeMessage(message: unknown): string {
|
|
57
|
+
return `${JSON.stringify(message)}\n`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Splits a stream into complete lines, keeping the trailing partial line. */
|
|
61
|
+
export function splitLines(buffer: string, chunk: string): { lines: string[], rest: string } {
|
|
62
|
+
const parts = (buffer + chunk).split('\n')
|
|
63
|
+
const rest = parts.pop() ?? ''
|
|
64
|
+
return { lines: parts.filter(line => line.trim() !== ''), rest }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return typeof value === 'object' && value !== null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function decodeMessage(line: string): DecodedMessage {
|
|
72
|
+
let parsed: unknown
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(line)
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return { ok: false, error: `invalid JSON: ${(error as Error).message}` }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!isRecord(parsed))
|
|
81
|
+
return { ok: false, error: 'message must be an object' }
|
|
82
|
+
|
|
83
|
+
if (parsed.type === 'event') {
|
|
84
|
+
if (typeof parsed.kind !== 'string')
|
|
85
|
+
return { ok: false, error: 'event.kind must be a string' }
|
|
86
|
+
return { ok: true, message: parsed as unknown as HelperEvent }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof parsed.id !== 'number')
|
|
90
|
+
return { ok: false, error: 'response.id must be a number' }
|
|
91
|
+
|
|
92
|
+
if (parsed.ok === true)
|
|
93
|
+
return { ok: true, message: { id: parsed.id, ok: true, result: parsed.result } }
|
|
94
|
+
|
|
95
|
+
if (parsed.ok === false) {
|
|
96
|
+
const error = isRecord(parsed.error) ? parsed.error : {}
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
message: {
|
|
100
|
+
id: parsed.id,
|
|
101
|
+
ok: false,
|
|
102
|
+
error: {
|
|
103
|
+
message: typeof error.message === 'string' ? error.message : 'helper request failed',
|
|
104
|
+
code: typeof error.code === 'string' ? error.code : undefined,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { ok: false, error: 'response.ok must be a boolean' }
|
|
111
|
+
}
|