unplugin-https-reverse-proxy 4.0.3 → 4.1.0-beta.2
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-MD5CVZP7.js → chunk-7CA3EIIX.js} +682 -224
- package/dist/{chunk-RSBXFJ3Z.js → chunk-C5A7XLKL.js} +9 -2
- package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
- package/dist/chunk-RKHJN6KY.js +53 -0
- package/dist/esbuild.cjs +790 -303
- package/dist/esbuild.js +4 -3
- package/dist/hmr-sse-bridge.cjs +12 -4
- package/dist/hmr-sse-bridge.d.cts +4 -3
- package/dist/hmr-sse-bridge.d.ts +4 -3
- package/dist/hmr-sse-bridge.js +5 -3
- package/dist/index.cjs +790 -303
- package/dist/index.js +4 -3
- package/dist/nuxt.cjs +787 -292
- package/dist/nuxt.js +5 -9
- 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 +790 -303
- package/dist/rollup.js +4 -3
- package/dist/rspack.cjs +790 -303
- package/dist/rspack.js +4 -3
- package/dist/utils.cjs +0 -28
- package/dist/utils.d.cts +1 -3
- package/dist/utils.d.ts +1 -3
- package/dist/utils.js +1 -5
- package/dist/vite.cjs +790 -303
- package/dist/vite.js +4 -3
- package/dist/webpack.cjs +790 -303
- package/dist/webpack.js +4 -3
- package/package.json +1 -4
- package/src/caddy/caddyfile.ts +1 -0
- package/src/caddy/index.ts +183 -110
- package/src/caddy/privileged.ts +118 -0
- package/src/caddy/utils.ts +0 -43
- package/src/hmr-sse-bridge.ts +14 -3
- package/src/index.ts +4 -18
- package/src/nuxt.ts +1 -6
- package/src/privileged/bootstrap.ts +174 -0
- package/src/privileged/client.ts +287 -0
- package/src/privileged/entry.ts +45 -0
- package/src/privileged/hosts-file.ts +142 -0
- package/src/privileged/launch.ts +193 -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 +114 -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,7 @@ 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 { consola, generateQrcode, getIpv4List,
|
|
10
|
+
import { consola, generateQrcode, getIpv4List, once } from './utils'
|
|
11
11
|
|
|
12
12
|
let config: ResolvedConfig
|
|
13
13
|
|
|
@@ -96,10 +96,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
96
96
|
if (!enable)
|
|
97
97
|
return
|
|
98
98
|
|
|
99
|
-
if (!isAdmin()) {
|
|
100
|
-
consola.warn('please run as administrator')
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
99
|
if (!target) {
|
|
104
100
|
consola.fail('please provide target')
|
|
105
101
|
return
|
|
@@ -123,17 +119,17 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
123
119
|
|
|
124
120
|
server.printUrls = () => vitePrintUrls(options, source, target, base, _printUrls)
|
|
125
121
|
|
|
126
|
-
// Setup SSE bridge for iOS Safari WSS workaround
|
|
122
|
+
// Setup SSE bridge for macOS/iOS Safari WSS workaround
|
|
127
123
|
if (options.https) {
|
|
128
124
|
setupHmrSseBridge(server)
|
|
129
125
|
hmrSseBridgeActive = true
|
|
130
|
-
consola.info('HMR SSE bridge enabled for iOS Safari WSS workaround')
|
|
126
|
+
consola.info('HMR SSE bridge enabled for macOS/iOS Safari WSS workaround')
|
|
131
127
|
}
|
|
132
128
|
},
|
|
133
129
|
transformIndexHtml() {
|
|
134
130
|
if (!hmrSseBridgeActive)
|
|
135
131
|
return []
|
|
136
|
-
// Inject client-side script to patch WebSocket on iOS Safari
|
|
132
|
+
// Inject client-side script to patch WebSocket on macOS/iOS Safari
|
|
137
133
|
return [{
|
|
138
134
|
tag: 'script',
|
|
139
135
|
children: hmrSseBridgeClientScript,
|
|
@@ -153,11 +149,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
153
149
|
if (!enable)
|
|
154
150
|
return
|
|
155
151
|
|
|
156
|
-
if (!isAdmin()) {
|
|
157
|
-
consola.warn('please run as administrator')
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
|
|
161
152
|
if (!target) {
|
|
162
153
|
consola.fail('please provide target')
|
|
163
154
|
return
|
|
@@ -231,11 +222,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
231
222
|
if (!enable)
|
|
232
223
|
return
|
|
233
224
|
|
|
234
|
-
if (!isAdmin()) {
|
|
235
|
-
consola.warn('please run as administrator')
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
|
|
239
225
|
if (!target) {
|
|
240
226
|
consola.fail('please provide target')
|
|
241
227
|
return
|
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,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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
export interface HelperEntry {
|
|
6
|
+
/**
|
|
7
|
+
* Executable started through sudo. This is the node runtime, so the caller
|
|
8
|
+
* only has to append `args` and the helper's own flags.
|
|
9
|
+
*/
|
|
10
|
+
entry: string
|
|
11
|
+
/** Script (and loader) arguments that follow the runtime. */
|
|
12
|
+
args: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResolveHelperEntryOptions {
|
|
16
|
+
/** Directory of the running bundle: `dist` in published builds, `src` from source. */
|
|
17
|
+
moduleDir: string
|
|
18
|
+
/** Node executable used to run a compiled helper. */
|
|
19
|
+
nodeBin?: string
|
|
20
|
+
exists?: (path: string) => boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Locates the helper entry next to the running module.
|
|
25
|
+
*
|
|
26
|
+
* Published builds ship a compiled `privileged-helper.js` and start it with
|
|
27
|
+
* node; running from the repository uses `tsx` so the TypeScript source works
|
|
28
|
+
* without a build step.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveHelperEntry(options: ResolveHelperEntryOptions): HelperEntry {
|
|
31
|
+
const { moduleDir, nodeBin = process.execPath, exists = existsSync } = options
|
|
32
|
+
|
|
33
|
+
for (const name of ['privileged-helper.js', 'privileged-helper.cjs', 'privileged-helper.mjs']) {
|
|
34
|
+
const candidate = join(moduleDir, name)
|
|
35
|
+
if (exists(candidate))
|
|
36
|
+
return { entry: nodeBin, args: [candidate] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const source = join(moduleDir, 'privileged-helper.ts')
|
|
40
|
+
const tsxCli = join(moduleDir, '../node_modules/tsx/dist/cli.mjs')
|
|
41
|
+
if (exists(source) && exists(tsxCli))
|
|
42
|
+
return { entry: nodeBin, args: [tsxCli, source] }
|
|
43
|
+
|
|
44
|
+
throw new Error(`privileged helper entry not found in ${moduleDir}. Build the package first.`)
|
|
45
|
+
}
|