@linxin666/dsh-ssh 0.2.9 → 0.3.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/lib/index.js CHANGED
@@ -2226,6 +2226,63 @@ var SshEngine = class {
2226
2226
  }
2227
2227
  };
2228
2228
  //#endregion
2229
+ //#region src/http.ts
2230
+ /** Default body cap for readJsonBody: 64 KiB. */
2231
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
2232
+ /** Family-default JSON response headers; callers may append or override. */
2233
+ const JSON_HEADERS = {
2234
+ "content-type": "application/json; charset=utf-8",
2235
+ "referrer-policy": "no-referrer"
2236
+ };
2237
+ /**
2238
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
2239
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
2240
+ * Overflow destroys the request instead of draining the remainder (no drain
2241
+ * call, matching the current repo-wide behavior); callers must not keep
2242
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
2243
+ * also yield null.
2244
+ */
2245
+ async function readJsonBody(req, opts = {}) {
2246
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES;
2247
+ const chunks = [];
2248
+ let size = 0;
2249
+ for await (const chunk of req) {
2250
+ const buffer = chunk;
2251
+ size += buffer.length;
2252
+ if (size > maxBytes) {
2253
+ req.destroy();
2254
+ return null;
2255
+ }
2256
+ chunks.push(buffer);
2257
+ }
2258
+ const text = Buffer.concat(chunks).toString("utf8");
2259
+ if (text === "") return null;
2260
+ try {
2261
+ const parsed = JSON.parse(text);
2262
+ if (opts.objectOnly && !isJsonObject(parsed)) return null;
2263
+ return parsed;
2264
+ } catch {
2265
+ return null;
2266
+ }
2267
+ }
2268
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
2269
+ function isJsonObject(value) {
2270
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2271
+ }
2272
+ /**
2273
+ * Write one JSON response. Default headers are the family defaults
2274
+ * (content-type and referrer-policy); caller headers are appended or
2275
+ * override them.
2276
+ */
2277
+ function writeJson(res, status, body, headers = {}) {
2278
+ const payload = JSON.stringify(body);
2279
+ res.writeHead(status, {
2280
+ ...JSON_HEADERS,
2281
+ ...headers
2282
+ });
2283
+ res.end(payload);
2284
+ }
2285
+ //#endregion
2229
2286
  //#region src/loopback.ts
2230
2287
  /** IPv4 127/8 predicate (four decimal octets, first == 127). */
2231
2288
  function isIPv4Loopback(v4) {
@@ -2294,8 +2351,6 @@ const SSH_API = {
2294
2351
  * these endpoints execute commands on remote servers, so LAN-exposed dsh web
2295
2352
  * deployments must not serve them.
2296
2353
  */
2297
- /** Cap on JSON request bodies (host entries and exec payloads are small). */
2298
- const MAX_JSON_BODY_BYTES = 64 * 1024;
2299
2354
  /** Cap on declared upload bodies (staged to disk before SFTP). */
2300
2355
  const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;
2301
2356
  /**
@@ -2308,32 +2363,6 @@ const terminalWss = new WebSocketServer({ noServer: true });
2308
2363
  const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
2309
2364
  /** …and resume once it drains below this. */
2310
2365
  const BACKPRESSURE_LOW_WATER = 512 * 1024;
2311
- /** One JSON response. */
2312
- function writeJson(res, status, body) {
2313
- const payload = JSON.stringify(body);
2314
- res.writeHead(status, {
2315
- "content-type": "application/json; charset=utf-8",
2316
- "referrer-policy": "no-referrer"
2317
- });
2318
- res.end(payload);
2319
- }
2320
- /** Read a JSON request body (undefined when too large or unparseable). */
2321
- async function readJsonBody(req) {
2322
- const chunks = [];
2323
- let size = 0;
2324
- for await (const chunk of req) {
2325
- const buffer = chunk;
2326
- size += buffer.length;
2327
- if (size > MAX_JSON_BODY_BYTES) return void 0;
2328
- chunks.push(buffer);
2329
- }
2330
- try {
2331
- const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2332
- return typeof parsed === "object" && parsed !== null ? parsed : void 0;
2333
- } catch {
2334
- return;
2335
- }
2336
- }
2337
2366
  /** URL query helper (first value, decoded). */
2338
2367
  function queryParam(url, name) {
2339
2368
  const value = url.searchParams.get(name);
@@ -2382,7 +2411,7 @@ function makeRoutes(deps) {
2382
2411
  }
2383
2412
  if (method === "POST") {
2384
2413
  const body = await readJsonBody(req);
2385
- if (body === void 0) {
2414
+ if (body === null) {
2386
2415
  writeJson(res, 400, { error: "invalid JSON body" });
2387
2416
  return;
2388
2417
  }
@@ -2405,7 +2434,7 @@ function makeRoutes(deps) {
2405
2434
  }
2406
2435
  if (method === "PATCH") {
2407
2436
  const body = await readJsonBody(req);
2408
- if (body === void 0) {
2437
+ if (body === null) {
2409
2438
  writeJson(res, 400, { error: "invalid JSON body" });
2410
2439
  return;
2411
2440
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared JSON body/response helpers for the host route families: one strict
3
+ * bounded body reader, one lenient bounded body reader, one JSON object
4
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
6
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
7
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
8
+ *
9
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
10
+ * edit this shared source and re-run the sync instead of editing a copy.
11
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
12
+ * belong in the same change as its introduction.
13
+ * @module dsh-web-ui-shared/host/http
14
+ */
15
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
16
+ /**
17
+ * Strict bounded body reader: parse a request body of at most maxBytes as
18
+ * JSON.
19
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
20
+ * invalid or empty payload.
21
+ */
22
+ export declare function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown>;
23
+ /**
24
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
25
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
26
+ * Overflow destroys the request instead of draining the remainder (no drain
27
+ * call, matching the current repo-wide behavior); callers must not keep
28
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
29
+ * also yield null.
30
+ */
31
+ export declare function readJsonBody(req: IncomingMessage, opts?: {
32
+ maxBytes?: number;
33
+ objectOnly?: boolean;
34
+ }): Promise<unknown | null>;
35
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
36
+ export declare function asJsonObject(value: unknown): Record<string, unknown> | undefined;
37
+ /**
38
+ * Write one JSON response. Default headers are the family defaults
39
+ * (content-type and referrer-policy); caller headers are appended or
40
+ * override them.
41
+ */
42
+ export declare function writeJson(res: ServerResponse, status: number, body: unknown, headers?: OutgoingHttpHeaders): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.2.9",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -57,10 +57,12 @@ export function SshPanel({ controller, api, terminalFont }: SshPanelProps) {
57
57
  return (
58
58
  <div className={css.panel} data-dsh-plugin="ssh">
59
59
  <div className={css.panelHeader}>
60
+ {/* Shared hook: dsh-web-ui-all offsets center-view back controls beside the collapsed mobile sidebar. */}
60
61
  <button
61
62
  type="button"
62
63
  className={`${css.ghostButton} ${css.backButton}`}
63
64
  aria-label={tt('panel.backToConversation')}
65
+ data-dsh-center-view-back=""
64
66
  onClick={() => { controller.close() }}
65
67
  >
66
68
  <span aria-hidden="true">‹</span>
@@ -66,12 +66,12 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
66
66
  }
67
67
 
68
68
  .entry:hover {
69
- background: var(--dsw-specific-sidebar-nav-item-hover);
69
+ background: var(--dsw-alias-interactive-bg-hover);
70
70
  color: var(--dsw-alias-label-primary);
71
71
  }
72
72
 
73
73
  .entry[data-active] {
74
- background: var(--dsw-specific-sidebar-nav-item-active);
74
+ background: var(--dsw-alias-interactive-bg-active);
75
75
  color: var(--dsw-alias-label-primary);
76
76
  font-weight: 600;
77
77
  }
@@ -100,9 +100,10 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
100
100
  [data-dsh-frame][data-sidebar-collapsed] .entry {
101
101
  justify-content: center;
102
102
  padding: 0;
103
- width: 40px;
104
- min-height: 40px;
105
- margin-inline: auto;
103
+ width: 36px;
104
+ min-height: 36px;
105
+ margin: 0 auto 12px;
106
+ border-radius: 50%;
106
107
  }
107
108
 
108
109
  [data-dsh-frame][data-sidebar-collapsed] .entryLabel {
package/src/http.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Shared JSON body/response helpers for the host route families: one strict
4
+ * bounded body reader, one lenient bounded body reader, one JSON object
5
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
6
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
9
+ *
10
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
11
+ * edit this shared source and re-run the sync instead of editing a copy.
12
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
13
+ * belong in the same change as its introduction.
14
+ * @module dsh-web-ui-shared/host/http
15
+ */
16
+
17
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
18
+
19
+ /** Default body cap for readJsonBody: 64 KiB. */
20
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
21
+
22
+ /** Family-default JSON response headers; callers may append or override. */
23
+ const JSON_HEADERS = {
24
+ 'content-type': 'application/json; charset=utf-8',
25
+ 'referrer-policy': 'no-referrer',
26
+ } satisfies OutgoingHttpHeaders
27
+
28
+ /**
29
+ * Strict bounded body reader: parse a request body of at most maxBytes as
30
+ * JSON.
31
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
32
+ * invalid or empty payload.
33
+ */
34
+ export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
35
+ const chunks: Buffer[] = []
36
+ let size = 0
37
+ for await (const chunk of req) {
38
+ const buffer = chunk as Buffer
39
+ size += buffer.length
40
+ if (size > maxBytes) throw new Error('body too large')
41
+ chunks.push(buffer)
42
+ }
43
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
44
+ }
45
+
46
+ /**
47
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
48
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
49
+ * Overflow destroys the request instead of draining the remainder (no drain
50
+ * call, matching the current repo-wide behavior); callers must not keep
51
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
52
+ * also yield null.
53
+ */
54
+ export async function readJsonBody(
55
+ req: IncomingMessage,
56
+ opts: { maxBytes?: number; objectOnly?: boolean } = {},
57
+ ): Promise<unknown | null> {
58
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
59
+ const chunks: Buffer[] = []
60
+ let size = 0
61
+ for await (const chunk of req) {
62
+ const buffer = chunk as Buffer
63
+ size += buffer.length
64
+ if (size > maxBytes) {
65
+ req.destroy()
66
+ return null
67
+ }
68
+ chunks.push(buffer)
69
+ }
70
+ const text = Buffer.concat(chunks).toString('utf8')
71
+ if (text === '') return null
72
+ try {
73
+ const parsed: unknown = JSON.parse(text)
74
+ if (opts.objectOnly && !isJsonObject(parsed)) return null
75
+ return parsed
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
82
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
84
+ }
85
+
86
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
87
+ export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
88
+ return isJsonObject(value) ? value : undefined
89
+ }
90
+
91
+ /**
92
+ * Write one JSON response. Default headers are the family defaults
93
+ * (content-type and referrer-policy); caller headers are appended or
94
+ * override them.
95
+ */
96
+ export function writeJson(
97
+ res: ServerResponse,
98
+ status: number,
99
+ body: unknown,
100
+ headers: OutgoingHttpHeaders = {},
101
+ ): void {
102
+ const payload = JSON.stringify(body)
103
+ res.writeHead(status, { ...JSON_HEADERS, ...headers })
104
+ res.end(payload)
105
+ }
package/src/routes.ts CHANGED
@@ -16,13 +16,11 @@ import { randomBytes } from 'node:crypto'
16
16
  import { WebSocket, WebSocketServer } from 'ws'
17
17
  import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
18
18
  import type { SshEngine, ShellSession } from './engine.ts'
19
+ import { readJsonBody, writeJson } from './http.ts'
19
20
  import { isLoopbackRequest } from './loopback.ts'
20
21
  import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
21
22
  import type { HostStore } from './store.ts'
22
23
 
23
- /** Cap on JSON request bodies (host entries and exec payloads are small). */
24
- const MAX_JSON_BODY_BYTES = 64 * 1024
25
-
26
24
  /** Cap on declared upload bodies (staged to disk before SFTP). */
27
25
  const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
28
26
 
@@ -39,31 +37,6 @@ const BACKPRESSURE_HIGH_WATER = 1024 * 1024
39
37
  /** …and resume once it drains below this. */
40
38
  const BACKPRESSURE_LOW_WATER = 512 * 1024
41
39
 
42
- /** One JSON response. */
43
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
44
- const payload = JSON.stringify(body)
45
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
46
- res.end(payload)
47
- }
48
-
49
- /** Read a JSON request body (undefined when too large or unparseable). */
50
- async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
51
- const chunks: Buffer[] = []
52
- let size = 0
53
- for await (const chunk of req) {
54
- const buffer = chunk as Buffer
55
- size += buffer.length
56
- if (size > MAX_JSON_BODY_BYTES) return undefined
57
- chunks.push(buffer)
58
- }
59
- try {
60
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
61
- return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
62
- } catch {
63
- return undefined
64
- }
65
- }
66
-
67
40
  /** URL query helper (first value, decoded). */
68
41
  function queryParam(url: URL, name: string): string | undefined {
69
42
  const value = url.searchParams.get(name)
@@ -129,8 +102,8 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
129
102
  return
130
103
  }
131
104
  if (method === 'POST') {
132
- const body = await readJsonBody(req)
133
- if (body === undefined) {
105
+ const body = (await readJsonBody(req)) as Record<string, unknown> | null
106
+ if (body === null) {
134
107
  writeJson(res, 400, { error: 'invalid JSON body' })
135
108
  return
136
109
  }
@@ -152,8 +125,8 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
152
125
  return
153
126
  }
154
127
  if (method === 'PATCH') {
155
- const body = await readJsonBody(req)
156
- if (body === undefined) {
128
+ const body = (await readJsonBody(req)) as Record<string, unknown> | null
129
+ if (body === null) {
157
130
  writeJson(res, 400, { error: 'invalid JSON body' })
158
131
  return
159
132
  }
@@ -203,7 +176,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
203
176
  path: SSH_API.test,
204
177
  handler: async (req, res) => {
205
178
  if (!guard(req, res, 'POST')) return
206
- const body = await readJsonBody(req)
179
+ const body = (await readJsonBody(req)) as Record<string, unknown> | null
207
180
  const alias = typeof body?.alias === 'string' ? body.alias : ''
208
181
  if (alias === '') {
209
182
  writeJson(res, 400, { error: 'alias is required' })
@@ -221,7 +194,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
221
194
  path: SSH_API.exec,
222
195
  handler: async (req, res) => {
223
196
  if (!guard(req, res, 'POST')) return
224
- const body = await readJsonBody(req)
197
+ const body = (await readJsonBody(req)) as Record<string, unknown> | null
225
198
  const alias = typeof body?.alias === 'string' ? body.alias : ''
226
199
  const command = typeof body?.command === 'string' ? body.command : ''
227
200
  if (alias === '' || command === '') {
@@ -241,7 +214,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
241
214
  path: SSH_API.cluster,
242
215
  handler: async (req, res) => {
243
216
  if (!guard(req, res, 'POST')) return
244
- const body = await readJsonBody(req)
217
+ const body = (await readJsonBody(req)) as Record<string, unknown> | null
245
218
  const command = typeof body?.command === 'string' ? body.command : ''
246
219
  if (command === '') {
247
220
  writeJson(res, 400, { error: 'command is required' })
@@ -285,7 +258,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
285
258
  path: SSH_API.tunnel,
286
259
  handler: async (req, res) => {
287
260
  if (!guard(req, res, 'POST')) return
288
- const body = await readJsonBody(req)
261
+ const body = (await readJsonBody(req)) as Record<string, unknown> | null
289
262
  const action = typeof body?.action === 'string' ? body.action : ''
290
263
  if (action === 'list') {
291
264
  writeJson(res, 200, { tunnels: engine.listTunnels() })