dsh-zen-remote 2.0.2 → 2.2.0
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/AGENTS.md +15 -1
- package/README.md +11 -5
- package/README.zh-CN.md +11 -5
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
- package/docs/adr/0002-relay-emulates-websocket.md +8 -0
- package/docs/interface.md +3 -1
- package/docs/plugin-bridge.md +140 -0
- package/docs/remote-access.en.md +57 -0
- package/docs/remote-access.md +63 -0
- package/lib/adapters/dsh-better-sidebar/index.js +144 -0
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
- package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
- package/lib/adapters/index.js +17 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/types.js +17 -0
- package/lib/adapters/types.js.map +1 -0
- package/lib/admin-routes.js +51 -0
- package/lib/admin-routes.js.map +1 -1
- package/lib/client-routes.js +18 -1
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +449 -45
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -1
- package/lib/http.js +10 -0
- package/lib/http.js.map +1 -1
- package/lib/index.js +246 -0
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +5 -2
- package/lib/intercept.js.map +1 -1
- package/lib/plugin-adapter-core.js +385 -0
- package/lib/plugin-adapter-core.js.map +1 -0
- package/lib/plugin-bridge.js +318 -0
- package/lib/plugin-bridge.js.map +1 -0
- package/lib/plugin-loopback.js +207 -0
- package/lib/plugin-loopback.js.map +1 -0
- package/lib/plugin-route-intercept.js +1103 -0
- package/lib/plugin-route-intercept.js.map +1 -0
- package/lib/plugin-tunnel.js +416 -0
- package/lib/plugin-tunnel.js.map +1 -0
- package/lib/relay-client.js +340 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +665 -7
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +17 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +42 -0
- package/lib/types/adapters/types.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +16 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts +0 -22
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/plugin-bridge.d.ts +79 -0
- package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +20 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +37 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +13 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +8 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/http.d.ts +7 -0
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +263 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -0
- package/lib/types/plugin-bridge.d.ts +187 -0
- package/lib/types/plugin-bridge.d.ts.map +1 -0
- package/lib/types/plugin-loopback.d.ts +104 -0
- package/lib/types/plugin-loopback.d.ts.map +1 -0
- package/lib/types/plugin-route-intercept.d.ts +114 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -0
- package/lib/types/plugin-tunnel.d.ts +96 -0
- package/lib/types/plugin-tunnel.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +81 -1
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +32 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/scripts/check-plugin-bridge-ui.mjs +168 -0
- package/scripts/check-settings-form.mjs +3 -1
- package/src/adapters/dsh-better-sidebar/index.ts +144 -0
- package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/types.ts +44 -0
- package/src/admin-routes.ts +64 -0
- package/src/client/effects/remote-session.ts +82 -10
- package/src/client/locales.ts +36 -0
- package/src/client/settings/SettingsSection.tsx +107 -3
- package/src/client/styles/remote-session.css.ts +70 -31
- package/src/client-data/plugin-bridge.ts +150 -0
- package/src/client-data/remote-status.ts +30 -0
- package/src/client-data/settings-form.ts +78 -0
- package/src/client-routes.ts +29 -1
- package/src/config.ts +32 -0
- package/src/http.ts +12 -0
- package/src/index.ts +252 -0
- package/src/intercept.ts +5 -2
- package/src/plugin-adapter-core.ts +530 -0
- package/src/plugin-bridge.ts +435 -0
- package/src/plugin-loopback.ts +299 -0
- package/src/plugin-route-intercept.ts +1193 -0
- package/src/plugin-tunnel.ts +470 -0
- package/src/relay-client.ts +413 -4
- package/src/relay-server.ts +687 -9
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin bridge's WebSocket byte tunnel (PB-10), server half. Per
|
|
3
|
+
* docs/adr/0002 the relay NEVER speaks WebSocket: a bridged upgrade route is
|
|
4
|
+
* carried as ONE NDJSON streaming response (server → client bytes) plus
|
|
5
|
+
* ordinary POSTs per chunk (client → server bytes), both ends treating the
|
|
6
|
+
* payload as OPAQUE BYTES — the WebSocket handshake and every frame are
|
|
7
|
+
* produced by the real plugin route on the server machine and arrive at the
|
|
8
|
+
* browser untouched.
|
|
9
|
+
*
|
|
10
|
+
* Open: `POST relay/v1/plugin-tunnel/open` — the same coordinate headers and
|
|
11
|
+
* the SAME validation chain as `v1/plugin` (shared {@link validatePluginCall};
|
|
12
|
+
* the route must additionally be declared `upgrade: true`), plus a small
|
|
13
|
+
* JSON body carrying exactly the four `sec-websocket-*` headers and the
|
|
14
|
+
* optional early head bytes. On success the relay dials the local web server
|
|
15
|
+
* itself (net/tls, loopback only), writes a hand-built upgrade request with
|
|
16
|
+
* the operator's cookie, and pumps socket bytes downstream line by line.
|
|
17
|
+
* Send/close: `POST relay/v1/plugin-tunnel/{send,close}` — ordered chunks
|
|
18
|
+
* upstream (one in-flight per tunnel, sequence-gated) and idempotent
|
|
19
|
+
* teardown.
|
|
20
|
+
*
|
|
21
|
+
* Every tunnel belongs to the device that opened it; another device's
|
|
22
|
+
* send/close is 404. Budgets, heartbeats and the slow-reader discipline
|
|
23
|
+
* mirror the routes this module sits beside.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { connect as netConnect } from 'node:net'
|
|
27
|
+
import { connect as tlsConnect } from 'node:tls'
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
|
+
import type { Socket } from 'node:net'
|
|
30
|
+
import { pluginBridgeError, responseJson } from './http.js'
|
|
31
|
+
import { routeUpgradesOf } from './plugin-bridge.js'
|
|
32
|
+
import type { BridgeRegistry, NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
|
|
33
|
+
import type { PluginLoopback } from './plugin-loopback.js'
|
|
34
|
+
|
|
35
|
+
/** The shared validation verdict for the two plugin routes (PB-3, reused by
|
|
36
|
+
* the tunnel open in PB-10): the whole chain from the coordinate headers to
|
|
37
|
+
* the consent wall, minus each route's own extras (the method check for
|
|
38
|
+
* `v1/plugin`, the upgrade mark here). First failure wins, in this order. */
|
|
39
|
+
export type PluginCallValidation =
|
|
40
|
+
| { ok: true; declaration: NormalizedDeclaration; route: NormalizedBridgeRoute; params: URLSearchParams; sessionId: string; normalizedQuery: string }
|
|
41
|
+
| { ok: false; status: number; code: string }
|
|
42
|
+
|
|
43
|
+
/** Validate one plugin-bridge call's coordinates — the parse-once query
|
|
44
|
+
* discipline (the re-serialization of THIS parse is the only string the
|
|
45
|
+
* tunnel's upgrade request may carry), the share table with its ancestor
|
|
46
|
+
* walk, and the live consent wall. `bridge.getConsent()` throwing reads as
|
|
47
|
+
* unknown consent, and unknown consent stays home. */
|
|
48
|
+
export function validatePluginCall(
|
|
49
|
+
bridge: { registry: BridgeRegistry; getConsent: () => Record<string, boolean> },
|
|
50
|
+
coords: { pluginName: string; pluginVersion: string; pluginDigest: string; routePath: string; rawQuery: string },
|
|
51
|
+
isAccessible: (sessionId: string) => boolean,
|
|
52
|
+
): PluginCallValidation {
|
|
53
|
+
const declaration = bridge.registry.get(coords.pluginName)
|
|
54
|
+
if (declaration === undefined) return { ok: false, status: 404, code: 'plugin-unknown' }
|
|
55
|
+
if (declaration.version !== coords.pluginVersion || declaration.digest !== coords.pluginDigest) {
|
|
56
|
+
return { ok: false, status: 409, code: 'plugin-mismatch' }
|
|
57
|
+
}
|
|
58
|
+
const route = declaration.routes.find((candidate) => candidate.path === coords.routePath)
|
|
59
|
+
if (route === undefined) return { ok: false, status: 404, code: 'route-undeclared' }
|
|
60
|
+
let params: URLSearchParams
|
|
61
|
+
try {
|
|
62
|
+
params = new URLSearchParams(coords.rawQuery)
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, status: 400, code: 'bad-query' }
|
|
65
|
+
}
|
|
66
|
+
const ids = params.getAll(route.sessionParam)
|
|
67
|
+
const sessionId = ids.length === 1 ? ids[0] : undefined
|
|
68
|
+
if (sessionId === undefined || sessionId === '') return { ok: false, status: 400, code: 'bad-query' }
|
|
69
|
+
if (!isAccessible(sessionId)) return { ok: false, status: 403, code: 'not-shared' }
|
|
70
|
+
if (route.scope === 'server') {
|
|
71
|
+
let consent: Record<string, boolean> = {}
|
|
72
|
+
try {
|
|
73
|
+
consent = bridge.getConsent()
|
|
74
|
+
} catch {
|
|
75
|
+
consent = {}
|
|
76
|
+
}
|
|
77
|
+
if (consent[coords.pluginName] !== true) return { ok: false, status: 403, code: 'consent-off' }
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, declaration, route, params, sessionId, normalizedQuery: params.toString() }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Largest single downstream data LINE (base64 will inflate by 4/3 — the cap
|
|
83
|
+
* is on the RAW bytes carried, and the line stays well under typical proxy
|
|
84
|
+
* line limits this way). */
|
|
85
|
+
export const TUNNEL_DOWN_CHUNK_BYTES = 48 * 1024
|
|
86
|
+
|
|
87
|
+
/** Largest upstream send chunk (the wire body of one `send`). */
|
|
88
|
+
export const TUNNEL_UP_CHUNK_BYTES = 64 * 1024
|
|
89
|
+
|
|
90
|
+
/** Concurrent tunnels per device. */
|
|
91
|
+
export const MAX_TUNNELS_PER_DEVICE = 8
|
|
92
|
+
|
|
93
|
+
/** The four WebSocket headers the open body may carry, verbatim. */
|
|
94
|
+
const TUNNEL_CLIENT_HEADERS = ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol', 'sec-websocket-extensions'] as const
|
|
95
|
+
|
|
96
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
97
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Header values must be plain strings — a CR/LF anywhere would inject into
|
|
101
|
+
* the hand-built upgrade request. */
|
|
102
|
+
function cleanHeaderValue(value: unknown): string | undefined {
|
|
103
|
+
if (typeof value !== 'string' || value === '' || /[\r\n]/.test(value)) return undefined
|
|
104
|
+
return value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One live tunnel. */
|
|
108
|
+
interface Tunnel {
|
|
109
|
+
device: string
|
|
110
|
+
upstream: Socket
|
|
111
|
+
/** The open request's NDJSON response — the downstream river. */
|
|
112
|
+
res: ServerResponse
|
|
113
|
+
/** The next upstream sequence the client must send. */
|
|
114
|
+
expectedSeq: number
|
|
115
|
+
heartbeat: ReturnType<typeof setInterval>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CreatePluginTunnelOptions {
|
|
119
|
+
bridge: { registry: BridgeRegistry; getConsent: () => Record<string, boolean>; loopback: PluginLoopback }
|
|
120
|
+
/** The loopback web server bind, re-read per tunnel (same live discipline
|
|
121
|
+
* as every target read in this plugin). */
|
|
122
|
+
getTarget: () => { protocol: 'http:' | 'https:'; port: number }
|
|
123
|
+
isAccessible: (sessionId: string) => boolean
|
|
124
|
+
/** Heartbeat interval for the open stream (the ping lines). */
|
|
125
|
+
heartbeatMs: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PluginTunnelRoutes {
|
|
129
|
+
/** `POST relay/v1/plugin-tunnel/open`. */
|
|
130
|
+
open(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|
131
|
+
/** `POST relay/v1/plugin-tunnel/send`. */
|
|
132
|
+
send(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|
133
|
+
/** `POST relay/v1/plugin-tunnel/close`. */
|
|
134
|
+
close(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|
135
|
+
/** Live tunnel count (the settings diagnostics). */
|
|
136
|
+
tunnelCount(): number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The tunnel table: id → the live entry. Every teardown path removes its
|
|
140
|
+
* own entry exactly once. */
|
|
141
|
+
export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTunnelRoutes {
|
|
142
|
+
const tunnels = new Map<string, Tunnel>()
|
|
143
|
+
|
|
144
|
+
/** Teardown, idempotent: end line (when the downstream still wants one
|
|
145
|
+
* and none was written), upstream destroyed, heartbeat cleared, entry
|
|
146
|
+
* gone. */
|
|
147
|
+
const teardown = (tunnel: Tunnel, id: string, reason: 'upstream-closed' | 'upstream-error' | 'client-closed' | null): void => {
|
|
148
|
+
if (tunnels.get(id) !== tunnel) return
|
|
149
|
+
tunnels.delete(id)
|
|
150
|
+
clearInterval(tunnel.heartbeat)
|
|
151
|
+
if (reason !== null && !tunnel.res.writableEnded && !tunnel.res.destroyed) {
|
|
152
|
+
writeLine(tunnel.res, { type: 'end', reason })
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
tunnel.res.end()
|
|
156
|
+
} catch {
|
|
157
|
+
// Already gone.
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
tunnel.upstream.destroy()
|
|
161
|
+
} catch {
|
|
162
|
+
// Already gone.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const writeLine = (res: ServerResponse, value: Record<string, unknown>): boolean => {
|
|
167
|
+
if (res.destroyed || res.writableEnded) return false
|
|
168
|
+
return res.write(Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const deviceOf = (req: IncomingMessage): string => {
|
|
172
|
+
const raw = req.headers['x-zen-remote-device']
|
|
173
|
+
const value = Array.isArray(raw) ? raw[0] : raw
|
|
174
|
+
return typeof value === 'string' ? value : ''
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One coordinate header of the open request. */
|
|
178
|
+
const coordOf = (req: IncomingMessage, name: string): string | undefined => {
|
|
179
|
+
const raw = req.headers[name]
|
|
180
|
+
const value = Array.isArray(raw) ? raw[0] : raw
|
|
181
|
+
return typeof value === 'string' ? value : undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
tunnelCount() {
|
|
186
|
+
return tunnels.size
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async open(req, res) {
|
|
190
|
+
const bridge = options.bridge
|
|
191
|
+
// The open body is a SMALL JSON object (four headers + optional head);
|
|
192
|
+
// anything past the shared body cap is refused by the reader itself.
|
|
193
|
+
let body: Record<string, unknown>
|
|
194
|
+
try {
|
|
195
|
+
const chunks: Buffer[] = []
|
|
196
|
+
let size = 0
|
|
197
|
+
for await (const chunk of req) {
|
|
198
|
+
size += (chunk as Buffer).length
|
|
199
|
+
if (size > 1024 * 1024) {
|
|
200
|
+
pluginBridgeError(res, 413, 'body-too-large')
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
chunks.push(Buffer.from(chunk as Uint8Array))
|
|
204
|
+
}
|
|
205
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
206
|
+
if (!isRecord(parsed)) {
|
|
207
|
+
pluginBridgeError(res, 400, 'bad-request')
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
body = parsed
|
|
211
|
+
} catch {
|
|
212
|
+
pluginBridgeError(res, 400, 'bad-request')
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
const rawHeaders = isRecord(body.headers) ? body.headers : undefined
|
|
216
|
+
// ALL four headers pass the CR/LF check unconditionally — confining it
|
|
217
|
+
// to the missing-key branch would let a poisoned OPTIONAL header
|
|
218
|
+
// through when key/version are fine, and the hand-built request below
|
|
219
|
+
// would write a literal `undefined` header for it.
|
|
220
|
+
for (const optional of ['sec-websocket-protocol', 'sec-websocket-extensions']) {
|
|
221
|
+
if (rawHeaders?.[optional] !== undefined && cleanHeaderValue(rawHeaders[optional]) === undefined) {
|
|
222
|
+
pluginBridgeError(res, 400, 'bad-request')
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const wsKey = rawHeaders === undefined ? undefined : cleanHeaderValue(rawHeaders['sec-websocket-key'])
|
|
227
|
+
const wsVersion = rawHeaders === undefined ? undefined : cleanHeaderValue(rawHeaders['sec-websocket-version'])
|
|
228
|
+
// The two headers a WebSocket handshake cannot exist without.
|
|
229
|
+
if (wsKey === undefined || wsVersion === undefined) {
|
|
230
|
+
pluginBridgeError(res, 400, 'bad-request')
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
let head: Buffer = Buffer.alloc(0)
|
|
234
|
+
if (body.head !== undefined) {
|
|
235
|
+
if (typeof body.head !== 'string') {
|
|
236
|
+
pluginBridgeError(res, 400, 'bad-request')
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
head = Buffer.from(body.head, 'base64')
|
|
240
|
+
if (head.length === 0 && body.head !== '') {
|
|
241
|
+
pluginBridgeError(res, 400, 'bad-request')
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const coords = {
|
|
247
|
+
pluginName: coordOf(req, 'x-zr-plugin') ?? '',
|
|
248
|
+
pluginVersion: coordOf(req, 'x-zr-plugin-version') ?? '',
|
|
249
|
+
pluginDigest: coordOf(req, 'x-zr-plugin-digest') ?? '',
|
|
250
|
+
routePath: coordOf(req, 'x-zr-path') ?? '',
|
|
251
|
+
rawQuery: coordOf(req, 'x-zr-query') ?? '',
|
|
252
|
+
}
|
|
253
|
+
if (
|
|
254
|
+
coords.pluginName === '' || coords.pluginVersion === '' || coords.pluginDigest === '' ||
|
|
255
|
+
coords.routePath === '' || !coords.routePath.startsWith('/')
|
|
256
|
+
) {
|
|
257
|
+
pluginBridgeError(res, 400, 'bad-request')
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
const validation = validatePluginCall(bridge, coords, options.isAccessible)
|
|
261
|
+
if (!validation.ok) {
|
|
262
|
+
pluginBridgeError(res, validation.status, validation.code)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
// Tunnel routes must be DECLARED as upgrades: an ordinary (HTTP)
|
|
266
|
+
// route has no WebSocket endpoint behind it to dial.
|
|
267
|
+
if (!routeUpgradesOf(validation.route)) {
|
|
268
|
+
pluginBridgeError(res, 404, 'route-undeclared')
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
const device = deviceOf(req)
|
|
272
|
+
if (([...tunnels.values()].filter((tunnel) => tunnel.device === device)).length >= MAX_TUNNELS_PER_DEVICE) {
|
|
273
|
+
pluginBridgeError(res, 429, 'busy')
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Dial the local web server with a hand-built upgrade request. The
|
|
278
|
+
// cookie authenticates as the operator (same loopback discipline as
|
|
279
|
+
// every bridged call); Origin is deliberately NOT written.
|
|
280
|
+
let cookie: string
|
|
281
|
+
try {
|
|
282
|
+
cookie = await bridge.loopback.cookieHeader()
|
|
283
|
+
} catch {
|
|
284
|
+
pluginBridgeError(res, 502, 'loopback-auth')
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
const target = options.getTarget()
|
|
288
|
+
let upstream: Socket
|
|
289
|
+
try {
|
|
290
|
+
// Self-signed https on the literal loopback: the dial is the pinned
|
|
291
|
+
// 127.0.0.1, so certificate identity carries no information (the
|
|
292
|
+
// same rule plugin-loopback applies to its own hops).
|
|
293
|
+
upstream =
|
|
294
|
+
target.protocol === 'https:'
|
|
295
|
+
? tlsConnect({ host: '127.0.0.1', port: target.port, rejectUnauthorized: false, servername: '127.0.0.1' })
|
|
296
|
+
: netConnect({ host: '127.0.0.1', port: target.port })
|
|
297
|
+
} catch {
|
|
298
|
+
pluginBridgeError(res, 502, 'internal')
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
const id = (await import('node:crypto')).randomBytes(16).toString('hex')
|
|
302
|
+
const tunnel: Tunnel = {
|
|
303
|
+
device,
|
|
304
|
+
upstream,
|
|
305
|
+
res,
|
|
306
|
+
expectedSeq: 0,
|
|
307
|
+
heartbeat: setInterval(() => {
|
|
308
|
+
writeLine(res, { type: 'ping' })
|
|
309
|
+
}, options.heartbeatMs),
|
|
310
|
+
}
|
|
311
|
+
if (typeof tunnel.heartbeat.unref === 'function') tunnel.heartbeat.unref()
|
|
312
|
+
tunnels.set(id, tunnel)
|
|
313
|
+
|
|
314
|
+
// The NDJSON river opens BEFORE the dial can succeed — the open line
|
|
315
|
+
// is the client's signal to stop HTTP-ing and start reading. A dial
|
|
316
|
+
// failure becomes an end line, not a lost connection.
|
|
317
|
+
res.writeHead(200, {
|
|
318
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
319
|
+
'cache-control': 'no-store, no-transform',
|
|
320
|
+
'x-accel-buffering': 'no',
|
|
321
|
+
})
|
|
322
|
+
res.write(Buffer.from(`${JSON.stringify({ type: 'open', tunnel: id })}\n`, 'utf8'))
|
|
323
|
+
|
|
324
|
+
const query = validation.normalizedQuery
|
|
325
|
+
const requestLines = [
|
|
326
|
+
`GET ${coords.routePath}${query === '' ? '' : `?${query}`} HTTP/1.1`,
|
|
327
|
+
`Host: 127.0.0.1:${target.port}`,
|
|
328
|
+
'Connection: Upgrade',
|
|
329
|
+
'Upgrade: websocket',
|
|
330
|
+
`Sec-WebSocket-Key: ${wsKey}`,
|
|
331
|
+
`Sec-WebSocket-Version: ${wsVersion}`,
|
|
332
|
+
...(rawHeaders?.['sec-websocket-protocol'] !== undefined ? [`Sec-WebSocket-Protocol: ${cleanHeaderValue(rawHeaders['sec-websocket-protocol'])}`] : []),
|
|
333
|
+
...(rawHeaders?.['sec-websocket-extensions'] !== undefined ? [`Sec-WebSocket-Extensions: ${cleanHeaderValue(rawHeaders['sec-websocket-extensions'])}`] : []),
|
|
334
|
+
...(cookie !== '' ? [`Cookie: ${cookie}`] : []),
|
|
335
|
+
'\r\n',
|
|
336
|
+
]
|
|
337
|
+
upstream.write(requestLines.join('\r\n'))
|
|
338
|
+
if (head.length > 0) upstream.write(head)
|
|
339
|
+
|
|
340
|
+
// Upstream bytes → downstream data lines, at most 48 KiB PER LINE, with
|
|
341
|
+
// the slow-reader discipline: a full socket pauses the UPSTREAM read, so
|
|
342
|
+
// a stalled client never grows this process's buffers. Whatever arrived
|
|
343
|
+
// is sent NOW — an interactive WebSocket speaks in tens of bytes, and
|
|
344
|
+
// holding a sub-chunk arrival for a fuller buffer would stall the
|
|
345
|
+
// handshake and every early frame (the PB-10 e2e hang).
|
|
346
|
+
let downstreamBuffer: Buffer = Buffer.alloc(0)
|
|
347
|
+
const pumpDownstream = (): void => {
|
|
348
|
+
while (downstreamBuffer.length > 0) {
|
|
349
|
+
const chunk = downstreamBuffer.subarray(0, TUNNEL_DOWN_CHUNK_BYTES)
|
|
350
|
+
downstreamBuffer = downstreamBuffer.subarray(chunk.length)
|
|
351
|
+
if (!writeLine(res, { type: 'data', b64: chunk.toString('base64') })) {
|
|
352
|
+
upstream.pause()
|
|
353
|
+
res.once('drain', () => upstream.resume())
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
upstream.on('data', (chunk: Buffer) => {
|
|
359
|
+
downstreamBuffer = Buffer.concat([downstreamBuffer, chunk])
|
|
360
|
+
pumpDownstream()
|
|
361
|
+
})
|
|
362
|
+
upstream.on('close', () => {
|
|
363
|
+
// Whatever bytes remained inside the last (sub-chunk) buffer still
|
|
364
|
+
// travel before the end line.
|
|
365
|
+
if (downstreamBuffer.length > 0 && !res.destroyed && !res.writableEnded) {
|
|
366
|
+
writeLine(res, { type: 'data', b64: downstreamBuffer.toString('base64') })
|
|
367
|
+
downstreamBuffer = Buffer.alloc(0)
|
|
368
|
+
}
|
|
369
|
+
teardown(tunnel, id, 'upstream-closed')
|
|
370
|
+
})
|
|
371
|
+
upstream.on('error', () => {
|
|
372
|
+
teardown(tunnel, id, 'upstream-error')
|
|
373
|
+
})
|
|
374
|
+
res.on('close', () => {
|
|
375
|
+
// The client is gone: no end line can be delivered.
|
|
376
|
+
teardown(tunnel, id, null)
|
|
377
|
+
})
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
async send(req, res) {
|
|
381
|
+
const tunnelId = coordOf(req, 'x-zr-tunnel') ?? ''
|
|
382
|
+
const seqRaw = coordOf(req, 'x-zr-seq') ?? ''
|
|
383
|
+
const tunnel = tunnels.get(tunnelId)
|
|
384
|
+
const device = deviceOf(req)
|
|
385
|
+
if (tunnel === undefined || tunnel.device !== device) {
|
|
386
|
+
pluginBridgeError(res, 404, 'tunnel-unknown')
|
|
387
|
+
// Drain the body so the client reads the refusal instead of a reset.
|
|
388
|
+
req.resume()
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
if (!/^\d+$/u.test(seqRaw)) {
|
|
392
|
+
pluginBridgeError(res, 400, 'bad-request')
|
|
393
|
+
req.resume()
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
const seq = Number(seqRaw)
|
|
397
|
+
if (seq !== tunnel.expectedSeq) {
|
|
398
|
+
// Out of order: the stream's ordering contract is broken — the
|
|
399
|
+
// tunnel closes (client-closed, the sending side's failure).
|
|
400
|
+
pluginBridgeError(res, 409, 'tunnel-out-of-order')
|
|
401
|
+
teardown(tunnel, tunnelId, 'client-closed')
|
|
402
|
+
req.resume()
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
const declaredLength = Number(req.headers['content-length'])
|
|
406
|
+
if (Number.isFinite(declaredLength) && declaredLength > TUNNEL_UP_CHUNK_BYTES) {
|
|
407
|
+
pluginBridgeError(res, 413, 'body-too-large')
|
|
408
|
+
req.resume()
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
const chunks: Buffer[] = []
|
|
412
|
+
let size = 0
|
|
413
|
+
let over = false
|
|
414
|
+
for await (const chunk of req) {
|
|
415
|
+
size += (chunk as Buffer).length
|
|
416
|
+
if (size > TUNNEL_UP_CHUNK_BYTES) {
|
|
417
|
+
over = true
|
|
418
|
+
break
|
|
419
|
+
}
|
|
420
|
+
chunks.push(Buffer.from(chunk as Uint8Array))
|
|
421
|
+
}
|
|
422
|
+
if (over) {
|
|
423
|
+
pluginBridgeError(res, 413, 'body-too-large')
|
|
424
|
+
req.resume()
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
const bytes = Buffer.concat(chunks)
|
|
428
|
+
tunnel.expectedSeq = seq + 1
|
|
429
|
+
// Backpressure: the answer waits for the upstream socket to take the
|
|
430
|
+
// bytes, so a stalled plugin makes THIS send slow instead of growing
|
|
431
|
+
// buffers here.
|
|
432
|
+
const flushed = tunnel.upstream.write(bytes)
|
|
433
|
+
if (!flushed) {
|
|
434
|
+
await new Promise<void>((resolve) => {
|
|
435
|
+
const settled = (): void => {
|
|
436
|
+
tunnel.upstream.off('drain', settled)
|
|
437
|
+
tunnel.upstream.off('close', settled)
|
|
438
|
+
tunnel.upstream.off('error', settled)
|
|
439
|
+
resolve()
|
|
440
|
+
}
|
|
441
|
+
tunnel.upstream.once('drain', settled)
|
|
442
|
+
tunnel.upstream.once('close', settled)
|
|
443
|
+
tunnel.upstream.once('error', settled)
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
if (tunnels.get(tunnelId) !== tunnel) {
|
|
447
|
+
// The tunnel died while the bytes were draining.
|
|
448
|
+
pluginBridgeError(res, 404, 'tunnel-unknown')
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
responseJson(res, 200, { ok: true })
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
async close(req, res) {
|
|
455
|
+
const tunnelId = coordOf(req, 'x-zr-tunnel') ?? ''
|
|
456
|
+
const tunnel = tunnels.get(tunnelId)
|
|
457
|
+
const device = deviceOf(req)
|
|
458
|
+
if (tunnel !== undefined && tunnel.device !== device) {
|
|
459
|
+
pluginBridgeError(res, 404, 'tunnel-unknown')
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
// Idempotent: a known tunnel closes (end line client-closed — the
|
|
463
|
+
// downstream's owner walked away), an unknown id answers ok.
|
|
464
|
+
if (tunnel !== undefined) {
|
|
465
|
+
teardown(tunnel, tunnelId, 'client-closed')
|
|
466
|
+
}
|
|
467
|
+
responseJson(res, 200, { ok: true })
|
|
468
|
+
},
|
|
469
|
+
}
|
|
470
|
+
}
|