dsh-multi-chat 0.6.7 → 1.0.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/README.md +20 -21
- package/README.zh.md +20 -21
- package/bin/dsh-multi-chat.mjs +5 -9
- package/lib/client.js +778 -0
- package/lib/client.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/index.js +57 -69
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallToggle.d.ts +1 -1
- package/lib/types/client/WallToggle.d.ts.map +1 -0
- package/lib/types/client/WallToggle.js +36 -0
- package/lib/types/client/WallToggle.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallView.d.ts +1 -1
- package/lib/types/client/WallView.d.ts.map +1 -0
- package/lib/types/client/WallView.js +218 -0
- package/lib/types/client/WallView.js.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +59 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +96 -0
- package/lib/types/client/locales.js.map +1 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/client/store.js +27 -0
- package/lib/types/client/store.js.map +1 -0
- package/lib/types/client/wall-injected.d.ts.map +1 -0
- package/lib/types/client/wall-injected.js +76 -0
- package/lib/types/client/wall-injected.js.map +1 -0
- package/lib/types/gateway.d.ts +57 -0
- package/lib/types/gateway.d.ts.map +1 -0
- package/lib/types/gateway.js +566 -0
- package/lib/types/gateway.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/index.d.ts +1 -16
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +550 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +26 -0
- package/lib/types/invariant.js.map +1 -0
- package/package.json +31 -17
- package/scripts/install-plugin.ps1 +6 -9
- package/src/client/WallToggle.module.css +26 -0
- package/src/client/WallToggle.tsx +52 -0
- package/src/client/WallView.module.css +190 -0
- package/src/client/WallView.tsx +406 -0
- package/src/client/index.ts +103 -0
- package/src/client/locales.ts +100 -0
- package/src/client/store.ts +40 -0
- package/src/client/wall-injected.ts +123 -0
- package/src/css-modules.d.ts +5 -0
- package/src/gateway.ts +627 -0
- package/src/index.ts +618 -0
- package/src/invariant.ts +34 -0
- package/plugin/dsh-client-ui-multi-wall/README.md +0 -26
- package/plugin/dsh-client-ui-multi-wall/README.zh.md +0 -26
- package/plugin/dsh-client-ui-multi-wall/cordis.patch.yml +0 -7
- package/plugin/dsh-client-ui-multi-wall/lib/client.js +0 -781
- package/plugin/dsh-client-ui-multi-wall/lib/invariant.js +0 -27
- package/plugin/dsh-client-ui-multi-wall/package.json +0 -88
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/index.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/locales.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/store.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/wall-injected.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/invariant.d.ts +0 -0
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline authenticated gateway for the multi-wall plugin's node half.
|
|
3
|
+
*
|
|
4
|
+
* The official `dsh web` CLI forbids `--host 0.0.0.0` (it would expose remote
|
|
5
|
+
* code execution to the network), so reaching a loopback-only DSH instance
|
|
6
|
+
* from a phone/another machine needs an auth-gated gateway in front of it.
|
|
7
|
+
*
|
|
8
|
+
* This module embeds that gateway directly in the plugin (no external
|
|
9
|
+
* `gateway.mjs` file needed at runtime): a raw TCP listener that authenticates
|
|
10
|
+
* a token (HMAC-signed session cookie), then transparently proxies to
|
|
11
|
+
* `127.0.0.1:<target-port>`, rewriting Host/Origin so the official `/api`
|
|
12
|
+
* browser-trust fence treats it as a local request. WebSocket upgrades pass
|
|
13
|
+
* through untouched because the socket is piped byte-for-byte after the head.
|
|
14
|
+
* The HTML document response is buffered once so a `crypto.randomUUID`
|
|
15
|
+
* polyfill can be injected for phones (insecure-origin HTTP); all other
|
|
16
|
+
* responses stream through unchanged.
|
|
17
|
+
*
|
|
18
|
+
* Zero dependencies: node:net + node:crypto only.
|
|
19
|
+
* @module @deepseek-ai/dsh-client-ui-multi-wall/gateway
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { connect as netConnect } from 'node:net'
|
|
23
|
+
import { createServer, type Server, type Socket } from 'node:net'
|
|
24
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
25
|
+
|
|
26
|
+
/** One parsed HTTP/1.1 request head. */
|
|
27
|
+
interface ParsedHead {
|
|
28
|
+
malformed: boolean
|
|
29
|
+
headBytes: number
|
|
30
|
+
method: string
|
|
31
|
+
target: string
|
|
32
|
+
version: string
|
|
33
|
+
headers: Array<[string, string]>
|
|
34
|
+
map: Record<string, string>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Options for {@link startGateway}. */
|
|
38
|
+
export interface GatewayOptions {
|
|
39
|
+
/** Loopback target port (the DSH instance to proxy by default). */
|
|
40
|
+
targetPort: number
|
|
41
|
+
/** Listen port; pass 0 to let the OS pick one. */
|
|
42
|
+
port: number
|
|
43
|
+
/** Login token (secret). */
|
|
44
|
+
token: string
|
|
45
|
+
/** Instance label shown on the login page. */
|
|
46
|
+
name: string
|
|
47
|
+
/** Session cookie lifetime in hours. */
|
|
48
|
+
maxAgeHours?: number
|
|
49
|
+
/**
|
|
50
|
+
* When set, the gateway routes `/gw/<port>/<path>` to `127.0.0.1:<port>`
|
|
51
|
+
* for any port in this list (the wall's phone panes). Other ports are
|
|
52
|
+
* rejected, so the phone cannot reach arbitrary loopback services.
|
|
53
|
+
*/
|
|
54
|
+
routedPorts?: number[]
|
|
55
|
+
/** Log lines (startup, login events). */
|
|
56
|
+
log?: (message: string) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Handle returned by {@link startGateway}. */
|
|
60
|
+
export interface GatewayHandle {
|
|
61
|
+
/** The OS-assigned listen port (equals `port` unless it was 0). */
|
|
62
|
+
port: number
|
|
63
|
+
/** The login token. */
|
|
64
|
+
token: string
|
|
65
|
+
/** Close the gateway listener. */
|
|
66
|
+
close: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Constant-time hex string comparison (length-guarded). */
|
|
70
|
+
function safeEqualHex(left: string, right: string): boolean {
|
|
71
|
+
const a = Buffer.from(left, 'hex')
|
|
72
|
+
const b = Buffer.from(right, 'hex')
|
|
73
|
+
return a.length !== 0 && a.length === b.length && timingSafeEqual(a, b)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Minimal HTTP/1.1 head parse; null until the head is complete. */
|
|
77
|
+
function parseHead(buffer: Buffer): ParsedHead | null {
|
|
78
|
+
const idx = buffer.indexOf('\r\n\r\n')
|
|
79
|
+
if (idx === -1) return null
|
|
80
|
+
const head = buffer.subarray(0, idx + 4).toString('latin1')
|
|
81
|
+
const lines = head.split('\r\n')
|
|
82
|
+
const requestLine = lines[0]?.match(/^(\S+)\s+(\S+)\s+(HTTP\/\d\.\d)$/)
|
|
83
|
+
const headers: Array<[string, string]> = []
|
|
84
|
+
const map: Record<string, string> = {}
|
|
85
|
+
for (const line of lines.slice(1)) {
|
|
86
|
+
const ci = line.indexOf(':')
|
|
87
|
+
if (ci === -1) continue
|
|
88
|
+
const name = line.slice(0, ci).trim().toLowerCase()
|
|
89
|
+
const value = line.slice(ci + 1).trim()
|
|
90
|
+
map[name] = value
|
|
91
|
+
headers.push([name, value])
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
malformed: requestLine === null,
|
|
95
|
+
headBytes: idx + 4,
|
|
96
|
+
method: requestLine?.[1] ?? '',
|
|
97
|
+
target: requestLine?.[2] ?? '',
|
|
98
|
+
version: requestLine?.[3] ?? '',
|
|
99
|
+
headers,
|
|
100
|
+
map,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Pathname of a request target (defaults to '/' on parse failure). */
|
|
105
|
+
function targetPath(target: string): string {
|
|
106
|
+
try {
|
|
107
|
+
return new URL(target, 'http://gateway.local').pathname
|
|
108
|
+
} catch {
|
|
109
|
+
return '/'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The dark-themed login page. */
|
|
114
|
+
function loginPage(name: string, next: string): string {
|
|
115
|
+
const nxt = (next ?? '').replace(/"/g, '"')
|
|
116
|
+
return `<!doctype html>
|
|
117
|
+
<html lang="zh-CN">
|
|
118
|
+
<head>
|
|
119
|
+
<meta charset="utf-8">
|
|
120
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
121
|
+
<title>${name} · 访问认证</title>
|
|
122
|
+
<style>
|
|
123
|
+
body { font-family: system-ui, sans-serif; background:#0d1117; color:#e6edf3;
|
|
124
|
+
display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
|
|
125
|
+
form { background:#161b22; border:1px solid #30363d; border-radius:12px;
|
|
126
|
+
padding:32px 36px; width:min(320px, 90vw); box-shadow:0 8px 30px #0006; }
|
|
127
|
+
h1 { font-size:16px; margin:0 0 18px; font-weight:600; }
|
|
128
|
+
label { font-size:13px; color:#8b949e; }
|
|
129
|
+
input { width:100%; box-sizing:border-box; margin:6px 0 16px; padding:9px 10px;
|
|
130
|
+
border-radius:8px; border:1px solid #30363d; background:#0d1117; color:#e6edf3; font-size:14px; }
|
|
131
|
+
button { width:100%; padding:10px; border:none; border-radius:8px; background:#2f81f7;
|
|
132
|
+
color:#fff; font-size:14px; font-weight:600; cursor:pointer; }
|
|
133
|
+
button:hover { background:#388bfd; }
|
|
134
|
+
</style>
|
|
135
|
+
</head>
|
|
136
|
+
<body>
|
|
137
|
+
<form method="post" action="/__gw__/login">
|
|
138
|
+
<h1>${name} · 需要访问口令</h1>
|
|
139
|
+
<label for="token">访问口令</label>
|
|
140
|
+
<input id="token" name="token" type="password" autofocus autocomplete="current-password">
|
|
141
|
+
<input type="hidden" name="next" value="${nxt}">
|
|
142
|
+
<button type="submit">进入</button>
|
|
143
|
+
</form>
|
|
144
|
+
</body>
|
|
145
|
+
</html>`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Send the login page as a 401 HTML response. */
|
|
149
|
+
function respondLogin(socket: Socket, name: string, next: string): void {
|
|
150
|
+
const body = Buffer.from(loginPage(name, next), 'utf8')
|
|
151
|
+
socket.write(
|
|
152
|
+
'HTTP/1.1 401 Unauthorized\r\n'
|
|
153
|
+
+ 'content-type: text/html; charset=utf-8\r\n'
|
|
154
|
+
+ `content-length: ${body.length}\r\n`
|
|
155
|
+
+ 'connection: close\r\n'
|
|
156
|
+
+ 'cache-control: no-store\r\n\r\n',
|
|
157
|
+
)
|
|
158
|
+
socket.write(body)
|
|
159
|
+
socket.end()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Send a small plain-text response and close the socket. */
|
|
163
|
+
function respondPlain(socket: Socket, status: string, text: string): void {
|
|
164
|
+
const body = Buffer.from(text, 'utf8')
|
|
165
|
+
socket.write(
|
|
166
|
+
`HTTP/1.1 ${status}\r\n`
|
|
167
|
+
+ 'content-type: text/plain; charset=utf-8\r\n'
|
|
168
|
+
+ `content-length: ${body.length}\r\n`
|
|
169
|
+
+ 'connection: close\r\n'
|
|
170
|
+
+ 'cache-control: no-store\r\n\r\n',
|
|
171
|
+
)
|
|
172
|
+
socket.write(body)
|
|
173
|
+
socket.end()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Send a 302 that mints a session cookie and drops the `token` query param.
|
|
178
|
+
* A `?token=` URL authenticates exactly one request, so on first arrival the
|
|
179
|
+
* gateway exchanges it for an HMAC-signed cookie and redirects to the same
|
|
180
|
+
* path without `token=` (keeps the secret out of the address bar / history).
|
|
181
|
+
*/
|
|
182
|
+
function respondSessionRedirect(socket: Socket, parsed: ParsedHead, cookie: string): void {
|
|
183
|
+
let location = parsed.target
|
|
184
|
+
try {
|
|
185
|
+
const url = new URL(parsed.target, 'http://gateway.local')
|
|
186
|
+
url.searchParams.delete('token')
|
|
187
|
+
location = url.pathname + url.search
|
|
188
|
+
} catch { /* keep raw target */ }
|
|
189
|
+
socket.write(
|
|
190
|
+
'HTTP/1.1 302 Found\r\n'
|
|
191
|
+
+ `location: ${location}\r\n`
|
|
192
|
+
+ `set-cookie: ${cookie}\r\n`
|
|
193
|
+
+ 'content-length: 0\r\n'
|
|
194
|
+
+ 'connection: close\r\n'
|
|
195
|
+
+ 'cache-control: no-store\r\n\r\n',
|
|
196
|
+
)
|
|
197
|
+
socket.end()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `crypto.randomUUID` exists only in a *secure context* (HTTPS or localhost).
|
|
202
|
+
* A phone reaching the instance via `http://<LAN-IP>:<gateway-port>` is an
|
|
203
|
+
* insecure origin, so the official DSH client throws
|
|
204
|
+
* `crypto.randomUUID is not a function` on its first RPC and renders blank.
|
|
205
|
+
* This polyfill supplies the same RFC-4122 v4 form using
|
|
206
|
+
* `crypto.getRandomValues`, which insecure origins still expose.
|
|
207
|
+
*/
|
|
208
|
+
const POLYFILL_SCRIPT = '<script>(function(){'
|
|
209
|
+
+ 'if(typeof crypto!=="undefined"&&typeof crypto.randomUUID==="undefined"&&typeof crypto.getRandomValues==="function"){'
|
|
210
|
+
+ 'crypto.randomUUID=function(){'
|
|
211
|
+
+ 'var b=crypto.getRandomValues(new Uint8Array(16));'
|
|
212
|
+
+ 'b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;'
|
|
213
|
+
+ 'var h=Array.prototype.map.call(b,function(x){return x.toString(16).padStart(2,"0")}).join("");'
|
|
214
|
+
+ 'return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20);'
|
|
215
|
+
+ '};}})();</script>'
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Rewrite a buffered HTML response body to inject the `randomUUID` polyfill
|
|
219
|
+
* into the top of `<head>` (before the DSH bootstrap script runs). Returns the
|
|
220
|
+
* new body, or `null` when the polyfill is not needed (already present, or the
|
|
221
|
+
* response carries no `<head>` to inject into).
|
|
222
|
+
*/
|
|
223
|
+
function injectPolyfill(body: Buffer): Buffer | null {
|
|
224
|
+
const html = body.toString('utf8')
|
|
225
|
+
if (html.includes('randomUUID')) return null
|
|
226
|
+
const headMatch = /<head[^>]*>/i.exec(html)
|
|
227
|
+
if (headMatch === null) return null
|
|
228
|
+
const insertAt = headMatch.index + headMatch[0].length
|
|
229
|
+
return Buffer.from(html.slice(0, insertAt) + POLYFILL_SCRIPT + html.slice(insertAt), 'utf8')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Rebuild the request head for the loopback target. Host and Origin point at
|
|
234
|
+
* `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
|
|
235
|
+
* request. Non-upgrade requests get `connection: close` (fresh connection per
|
|
236
|
+
* request keeps keep-alive from smuggling unrewritten Host headers); upgrades
|
|
237
|
+
* (WebSocket) keep `connection: Upgrade`. When `pathOverride` is set (a
|
|
238
|
+
* `/gw/<port>` route), the request line's path becomes the de-prefixed path.
|
|
239
|
+
*/
|
|
240
|
+
function rewriteHead(parsed: ParsedHead, targetPort: number, pathOverride?: string): string {
|
|
241
|
+
const upgrade = parsed.map.upgrade !== undefined
|
|
242
|
+
const requestTarget = pathOverride !== undefined ? pathOverride : parsed.target
|
|
243
|
+
const lines = [`${parsed.method} ${requestTarget} ${parsed.version}`]
|
|
244
|
+
for (const [name, value] of parsed.headers) {
|
|
245
|
+
if (name === 'host') {
|
|
246
|
+
lines.push(`host: 127.0.0.1:${targetPort}`)
|
|
247
|
+
} else if (name === 'origin') {
|
|
248
|
+
lines.push(`origin: http://127.0.0.1:${targetPort}`)
|
|
249
|
+
} else if (name === 'connection' || name === 'proxy-connection') {
|
|
250
|
+
// dropped; re-added below with the right semantics
|
|
251
|
+
} else {
|
|
252
|
+
lines.push(`${name}: ${value}`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
lines.push(upgrade ? 'connection: Upgrade' : 'connection: close')
|
|
256
|
+
return lines.join('\r\n') + '\r\n\r\n'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Start an in-process authenticated gateway for one loopback DSH instance.
|
|
261
|
+
* @param options - target, listen port, token, label, and lifetime.
|
|
262
|
+
* @returns a handle with the assigned port and token, and a close().
|
|
263
|
+
*/
|
|
264
|
+
export function startGateway(options: GatewayOptions): Promise<GatewayHandle> {
|
|
265
|
+
const {
|
|
266
|
+
targetPort,
|
|
267
|
+
port: requestedPort,
|
|
268
|
+
token,
|
|
269
|
+
name,
|
|
270
|
+
maxAgeHours = 12,
|
|
271
|
+
routedPorts,
|
|
272
|
+
log = () => {},
|
|
273
|
+
} = options
|
|
274
|
+
|
|
275
|
+
const allowedRouted = new Set<number>(routedPorts ?? [])
|
|
276
|
+
|
|
277
|
+
const hmac = (data: string) => createHmac('sha256', token).update(data).digest('hex')
|
|
278
|
+
const signSession = (exp: number) => {
|
|
279
|
+
const payload = Buffer.from(JSON.stringify({ exp })).toString('base64url')
|
|
280
|
+
return `${payload}.${hmac(payload)}`
|
|
281
|
+
}
|
|
282
|
+
const verifySession = (value: string) => {
|
|
283
|
+
const dot = value.lastIndexOf('.')
|
|
284
|
+
if (dot <= 0) return false
|
|
285
|
+
const payload = value.slice(0, dot)
|
|
286
|
+
const sig = value.slice(dot + 1)
|
|
287
|
+
if (!safeEqualHex(sig, hmac(payload))) return false
|
|
288
|
+
try {
|
|
289
|
+
const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString()) as { exp?: unknown }
|
|
290
|
+
return typeof parsed.exp === 'number' && parsed.exp > Date.now()
|
|
291
|
+
} catch {
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* How a request authenticates. When the answer is `query` (only the
|
|
298
|
+
* `?token=` matched), the caller must also mint a session cookie — a query
|
|
299
|
+
* token authenticates exactly one request, so without a cookie the page's
|
|
300
|
+
* follow-up fetches would 401 and render blank.
|
|
301
|
+
*/
|
|
302
|
+
type AuthKind = 'cookie' | 'bearer' | 'query' | 'none'
|
|
303
|
+
|
|
304
|
+
const authKind = (parsed: ParsedHead): AuthKind => {
|
|
305
|
+
const cookie = parsed.map.cookie ?? ''
|
|
306
|
+
const session = /(?:^|;\s*)dsh_gw_session=([^;]+)/.exec(cookie)
|
|
307
|
+
if (session !== null && verifySession(session[1] ?? '')) return 'cookie'
|
|
308
|
+
const bearer = parsed.map.authorization ?? ''
|
|
309
|
+
if (bearer.startsWith('Bearer ') && bearer.slice(7) === token) return 'bearer'
|
|
310
|
+
try {
|
|
311
|
+
if (new URL(parsed.target, 'http://gateway.local').searchParams.get('token') === token) return 'query'
|
|
312
|
+
} catch { /* fall through */ }
|
|
313
|
+
return 'none'
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Rate limiting per source IP (login POST only).
|
|
317
|
+
const attempts = new Map<string, { fails: number; blockedUntil?: number }>()
|
|
318
|
+
const isBlocked = (ip: string) => {
|
|
319
|
+
const entry = attempts.get(ip)
|
|
320
|
+
return entry !== undefined && entry.blockedUntil !== undefined && entry.blockedUntil > Date.now()
|
|
321
|
+
}
|
|
322
|
+
const registerFailure = (ip: string) => {
|
|
323
|
+
const now = Date.now()
|
|
324
|
+
const entry = attempts.get(ip) ?? { fails: 0 }
|
|
325
|
+
if (entry.blockedUntil !== undefined && entry.blockedUntil > now) return
|
|
326
|
+
entry.fails += 1
|
|
327
|
+
if (entry.fails >= 5) {
|
|
328
|
+
entry.fails = 0
|
|
329
|
+
entry.blockedUntil = now + 60_000
|
|
330
|
+
log(`rate-limited ${ip} for 60s`)
|
|
331
|
+
}
|
|
332
|
+
attempts.set(ip, entry)
|
|
333
|
+
if (attempts.size > 512) {
|
|
334
|
+
for (const [key, value] of attempts) {
|
|
335
|
+
if (value.fails === 0 && value.blockedUntil === undefined) attempts.delete(key)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const handleLogin = (socket: Socket, parsed: ParsedHead, initialBody: Buffer): void => {
|
|
341
|
+
let next = '/'
|
|
342
|
+
try {
|
|
343
|
+
next = new URL(parsed.target, 'http://gateway.local').searchParams.get('next') ?? '/'
|
|
344
|
+
} catch { /* keep default */ }
|
|
345
|
+
if (!/^\/(?!\/)/.test(next)) next = '/'
|
|
346
|
+
|
|
347
|
+
if (parsed.method !== 'POST') {
|
|
348
|
+
respondLogin(socket, name, next)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
const ip = socket.remoteAddress ?? 'unknown'
|
|
352
|
+
if (isBlocked(ip)) {
|
|
353
|
+
respondPlain(socket, '429', 'too many login attempts, try again later')
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
const contentLength = Number(parsed.map['content-length'] ?? 0)
|
|
357
|
+
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > 16384) {
|
|
358
|
+
respondPlain(socket, '413', 'login body too large')
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let collected = Buffer.from(initialBody)
|
|
363
|
+
const done = () => {
|
|
364
|
+
socket.off('data', onBody)
|
|
365
|
+
const submitted = new URLSearchParams(collected.toString('utf8')).get('token') ?? ''
|
|
366
|
+
if (submitted === token) {
|
|
367
|
+
const cookie = `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 3600_000)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`
|
|
368
|
+
socket.write(
|
|
369
|
+
'HTTP/1.1 302 Found\r\n'
|
|
370
|
+
+ `location: ${next}\r\n`
|
|
371
|
+
+ `set-cookie: ${cookie}\r\n`
|
|
372
|
+
+ 'content-length: 0\r\n'
|
|
373
|
+
+ 'connection: close\r\n\r\n',
|
|
374
|
+
)
|
|
375
|
+
socket.end()
|
|
376
|
+
log(`login ok from ${ip}`)
|
|
377
|
+
} else {
|
|
378
|
+
registerFailure(ip)
|
|
379
|
+
log(`login FAILED from ${ip}`)
|
|
380
|
+
respondLogin(socket, name, next)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const onBody = (chunk: Buffer) => {
|
|
384
|
+
collected = Buffer.concat([collected, chunk])
|
|
385
|
+
if (collected.length >= contentLength) done()
|
|
386
|
+
}
|
|
387
|
+
if (collected.length >= contentLength) {
|
|
388
|
+
done()
|
|
389
|
+
} else {
|
|
390
|
+
socket.on('data', onBody)
|
|
391
|
+
socket.once('end', done)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Decode an HTTP/1.1 `transfer-encoding: chunked` body back to its raw
|
|
397
|
+
* bytes. Returns `null` on malformed framing (caller then relays verbatim).
|
|
398
|
+
*/
|
|
399
|
+
const decodeChunked = (raw: Buffer): Buffer | null => {
|
|
400
|
+
const out: Buffer[] = []
|
|
401
|
+
let i = 0
|
|
402
|
+
const text = raw.toString('latin1')
|
|
403
|
+
while (true) {
|
|
404
|
+
const crlf = text.indexOf('\r\n', i)
|
|
405
|
+
if (crlf === -1) return null
|
|
406
|
+
const sizeHex = text.slice(i, crlf).trim()
|
|
407
|
+
const size = parseInt(sizeHex, 16)
|
|
408
|
+
if (!Number.isFinite(size) || size < 0) return null
|
|
409
|
+
i = crlf + 2
|
|
410
|
+
if (size === 0) {
|
|
411
|
+
// trailing chunk: consume optional trailers up to the terminating CRLF.
|
|
412
|
+
return Buffer.concat(out)
|
|
413
|
+
}
|
|
414
|
+
if (i + size > raw.length) return null
|
|
415
|
+
out.push(raw.subarray(i, i + size))
|
|
416
|
+
i += size + 2 // skip data + CRLF
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Relay the DSH target's response to the phone client. HTML document
|
|
422
|
+
* responses (the DSH shell) are buffered so the `randomUUID` polyfill can be
|
|
423
|
+
* injected before the bootstrap script runs; every other response
|
|
424
|
+
* (assets, API JSON, SSE streams, WebSocket upgrades) is piped byte-for-byte
|
|
425
|
+
* so streaming and framing stay untouched.
|
|
426
|
+
*/
|
|
427
|
+
const relayTarget = (socket: Socket, target: Socket): void => {
|
|
428
|
+
let head: Buffer = Buffer.alloc(0)
|
|
429
|
+
let bodyParts: Buffer[] | null = null
|
|
430
|
+
let relayed = false
|
|
431
|
+
|
|
432
|
+
const pipeThrough = () => {
|
|
433
|
+
if (relayed) return
|
|
434
|
+
relayed = true
|
|
435
|
+
if (head.length > 0) socket.write(head)
|
|
436
|
+
if (bodyParts !== null && bodyParts.length > 0) {
|
|
437
|
+
socket.write(Buffer.concat(bodyParts))
|
|
438
|
+
bodyParts = null
|
|
439
|
+
}
|
|
440
|
+
target.pipe(socket)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const finishInjection = (fullHead: Buffer, fullBody: Buffer): void => {
|
|
444
|
+
const rewritten = injectPolyfill(fullBody)
|
|
445
|
+
if (rewritten === null) {
|
|
446
|
+
socket.write(fullHead)
|
|
447
|
+
socket.write(fullBody)
|
|
448
|
+
socket.end()
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
// Strip framing we can no longer honor (chunked / keep-alive) and emit
|
|
452
|
+
// the whole document with an exact content-length + connection: close.
|
|
453
|
+
const lines = fullHead.toString('latin1').split('\r\n')
|
|
454
|
+
const filtered: string[] = []
|
|
455
|
+
for (const line of lines) {
|
|
456
|
+
const lower = line.toLowerCase()
|
|
457
|
+
if (lower.startsWith('transfer-encoding:')) continue
|
|
458
|
+
if (lower.startsWith('content-length:')) continue
|
|
459
|
+
if (lower.startsWith('connection:')) continue
|
|
460
|
+
filtered.push(line)
|
|
461
|
+
}
|
|
462
|
+
// Drop the trailing blank line(s) that separate head from body.
|
|
463
|
+
while (filtered.length > 0 && filtered[filtered.length - 1] === '') filtered.pop()
|
|
464
|
+
filtered.push(`content-length: ${rewritten.length}`, 'connection: close', '', '')
|
|
465
|
+
socket.write(Buffer.from(filtered.join('\r\n'), 'latin1'))
|
|
466
|
+
socket.write(rewritten)
|
|
467
|
+
socket.end()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
target.on('data', (chunk: Buffer) => {
|
|
471
|
+
if (relayed) return
|
|
472
|
+
const idx = head.indexOf('\r\n\r\n')
|
|
473
|
+
if (idx === -1) {
|
|
474
|
+
head = head.length === 0 ? chunk : Buffer.concat([head, chunk])
|
|
475
|
+
const newIdx = head.indexOf('\r\n\r\n')
|
|
476
|
+
if (newIdx === -1) {
|
|
477
|
+
if (head.length > 65536) pipeThrough()
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
// Head just completed: decide now whether to buffer the body.
|
|
481
|
+
const headBuf = head.subarray(0, newIdx + 4)
|
|
482
|
+
const bodyStart = head.subarray(newIdx + 4)
|
|
483
|
+
const headText = headBuf.toString('latin1')
|
|
484
|
+
const status = /^HTTP\/\d\.\d (\d{3})/.exec(headText)?.[1] ?? ''
|
|
485
|
+
const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? ''
|
|
486
|
+
const ce = /^content-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? ''
|
|
487
|
+
const isHtml = status === '200' && /text\/html/i.test(contentType)
|
|
488
|
+
const compressed = ce !== '' && !/identity/i.test(ce)
|
|
489
|
+
if (!isHtml || compressed) {
|
|
490
|
+
pipeThrough()
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
bodyParts = bodyStart.length > 0 ? [bodyStart] : []
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
// Head already parsed and we chose to buffer: append body.
|
|
497
|
+
if (bodyParts !== null) bodyParts.push(chunk)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
target.once('end', () => {
|
|
501
|
+
if (relayed || bodyParts === null) {
|
|
502
|
+
if (!relayed) socket.end()
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
const headBuf = head.subarray(0, head.indexOf('\r\n\r\n') + 4)
|
|
506
|
+
const headText = headBuf.toString('latin1')
|
|
507
|
+
const rawBody = Buffer.concat(bodyParts)
|
|
508
|
+
const te = /^transfer-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? ''
|
|
509
|
+
const body = /chunked/i.test(te) ? (decodeChunked(rawBody) ?? rawBody) : rawBody
|
|
510
|
+
finishInjection(headBuf, body)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
target.once('error', () => socket.destroy())
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const handleSocket = (socket: Socket): void => {
|
|
517
|
+
let buffer: Buffer = Buffer.alloc(0)
|
|
518
|
+
let headDone = false
|
|
519
|
+
let proxying = false
|
|
520
|
+
|
|
521
|
+
const onData = (chunk: Buffer) => {
|
|
522
|
+
if (headDone) return
|
|
523
|
+
buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk])
|
|
524
|
+
if (buffer.length > 65536) {
|
|
525
|
+
headDone = true
|
|
526
|
+
respondPlain(socket, '431', 'request head too large')
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
const parsed = parseHead(buffer)
|
|
530
|
+
if (parsed === null) return
|
|
531
|
+
|
|
532
|
+
headDone = true
|
|
533
|
+
if (parsed.malformed) {
|
|
534
|
+
respondPlain(socket, '400', 'malformed request')
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const path = targetPath(parsed.target)
|
|
539
|
+
if (path === '/__gw__/login') {
|
|
540
|
+
handleLogin(socket, parsed, buffer.subarray(parsed.headBytes))
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// `/gw/<port>/<path>` routes to a sibling loopback instance (the wall's
|
|
545
|
+
// phone panes). Only ports the host explicitly allowed are reachable, so
|
|
546
|
+
// the phone cannot probe arbitrary loopback services.
|
|
547
|
+
let routePort: number | null = null
|
|
548
|
+
let routePath: string | undefined
|
|
549
|
+
const routeMatch = /^\/gw\/(\d+)(\/.*)?$/.exec(path)
|
|
550
|
+
if (routeMatch !== null) {
|
|
551
|
+
const candidate = Number(routeMatch[1])
|
|
552
|
+
if (!Number.isInteger(candidate) || candidate <= 0 || !allowedRouted.has(candidate)) {
|
|
553
|
+
respondPlain(socket, '403', 'port not routable')
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
routePort = candidate
|
|
557
|
+
routePath = routeMatch[2] && routeMatch[2] !== '' ? routeMatch[2] : '/'
|
|
558
|
+
}
|
|
559
|
+
const effectivePort = routePort ?? targetPort
|
|
560
|
+
|
|
561
|
+
const kind = authKind(parsed)
|
|
562
|
+
if (kind === 'none') {
|
|
563
|
+
respondLogin(socket, name, path === '/' ? '/' : path)
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
// A `?token=` query authenticates only this request; exchange it for a
|
|
567
|
+
// session cookie and redirect (token dropped) so the browser stays
|
|
568
|
+
// authorized across the page's follow-up fetches without carrying the
|
|
569
|
+
// secret in every URL.
|
|
570
|
+
if (kind === 'query') {
|
|
571
|
+
const cookie = `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 3600_000)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`
|
|
572
|
+
respondSessionRedirect(socket, parsed, cookie)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const target = netConnect(effectivePort, '127.0.0.1')
|
|
577
|
+
const rest = buffer.subarray(parsed.headBytes)
|
|
578
|
+
let connected = false
|
|
579
|
+
target.on('connect', () => {
|
|
580
|
+
connected = true
|
|
581
|
+
target.write(rewriteHead(parsed, effectivePort, routePath))
|
|
582
|
+
if (rest.length > 0) target.write(rest)
|
|
583
|
+
proxying = true
|
|
584
|
+
socket.pipe(target)
|
|
585
|
+
relayTarget(socket, target)
|
|
586
|
+
})
|
|
587
|
+
target.on('error', (error: NodeJS.ErrnoException) => {
|
|
588
|
+
if (!connected) {
|
|
589
|
+
respondPlain(socket, '502', `cannot reach target 127.0.0.1:${effectivePort}: ${error.message}`)
|
|
590
|
+
}
|
|
591
|
+
socket.destroy()
|
|
592
|
+
})
|
|
593
|
+
socket.on('error', () => target.destroy())
|
|
594
|
+
socket.on('close', () => { if (proxying) target.destroy() })
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
socket.on('data', onData)
|
|
598
|
+
socket.on('error', () => { socket.destroy() })
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return new Promise((resolve, reject) => {
|
|
602
|
+
const server: Server = createServer(handleSocket)
|
|
603
|
+
const onError = (error: NodeJS.ErrnoException) => {
|
|
604
|
+
// EACCES (Windows excluded port range / no bind permission) and
|
|
605
|
+
// EADDRINUSE (port taken) fall back to an OS-assigned free port.
|
|
606
|
+
if ((error.code === 'EACCES' || error.code === 'EADDRINUSE') && requestedPort !== 0) {
|
|
607
|
+
log(`gateway port ${requestedPort} unavailable (${error.code}), using an OS-assigned port`)
|
|
608
|
+
server.listen(0, '0.0.0.0')
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
server.removeListener('error', onError)
|
|
612
|
+
reject(error)
|
|
613
|
+
}
|
|
614
|
+
server.on('error', onError)
|
|
615
|
+
server.listen(requestedPort, '0.0.0.0', () => {
|
|
616
|
+
server.removeListener('error', onError)
|
|
617
|
+
const address = server.address()
|
|
618
|
+
const assignedPort = typeof address === 'object' && address !== null ? address.port : requestedPort
|
|
619
|
+
log(`gateway listening 0.0.0.0:${assignedPort} -> 127.0.0.1:${targetPort}`)
|
|
620
|
+
resolve({
|
|
621
|
+
port: assignedPort,
|
|
622
|
+
token,
|
|
623
|
+
close: () => { server.close() },
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
}
|