@linxin666/dsh-ssh 0.1.1
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 +75 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +15638 -0
- package/lib/index.js +3297 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +72 -0
- package/lib/types/client/index.d.ts +37 -0
- package/lib/types/client/locales.d.ts +137 -0
- package/lib/types/client/mount.d.ts +12 -0
- package/lib/types/client/panel/ClusterTab.d.ts +7 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/SshPanel.d.ts +13 -0
- package/lib/types/client/panel/TerminalTab.d.ts +11 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
- package/lib/types/client/panel/controller.d.ts +22 -0
- package/lib/types/client/panel/helpers.d.ts +14 -0
- package/lib/types/client/panel/xterm.css.d.ts +2 -0
- package/lib/types/client/sidebar-entry.d.ts +24 -0
- package/lib/types/engine.d.ts +135 -0
- package/lib/types/index.d.ts +40 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/protocol.d.ts +189 -0
- package/lib/types/routes.d.ts +29 -0
- package/lib/types/store.d.ts +54 -0
- package/lib/types/tools.d.ts +18 -0
- package/package.json +106 -0
- package/src/client/api.ts +363 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +68 -0
- package/src/client/locales.ts +284 -0
- package/src/client/mount.tsx +83 -0
- package/src/client/panel/ClusterTab.tsx +123 -0
- package/src/client/panel/HostFormDialog.tsx +221 -0
- package/src/client/panel/HostsTab.tsx +166 -0
- package/src/client/panel/SshPanel.tsx +77 -0
- package/src/client/panel/TerminalTab.tsx +176 -0
- package/src/client/panel/TransferTab.tsx +232 -0
- package/src/client/panel/TunnelsTab.tsx +177 -0
- package/src/client/panel/controller.ts +48 -0
- package/src/client/panel/helpers.ts +26 -0
- package/src/client/panel/panel.module.css +833 -0
- package/src/client/panel/xterm.css.ts +2 -0
- package/src/client/sidebar-entry.ts +123 -0
- package/src/engine.ts +780 -0
- package/src/index.ts +155 -0
- package/src/invariant.ts +4 -0
- package/src/protocol.ts +188 -0
- package/src/routes.ts +540 -0
- package/src/store.ts +344 -0
- package/src/tools.ts +371 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /api/dsh-ssh route family: host CRUD, exec, cluster, SFTP transfer
|
|
3
|
+
* (NDJSON progress stream for uploads, binary stream for downloads), remote
|
|
4
|
+
* listing, tunnels, and the WebSocket PTY terminal upgrade. Every route
|
|
5
|
+
* carries a loopback-only trust fence (plus browser same-origin markers) —
|
|
6
|
+
* these endpoints execute commands on remote servers, so LAN-exposed dsh web
|
|
7
|
+
* deployments must not serve them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'
|
|
11
|
+
import { unlink } from 'node:fs/promises'
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
13
|
+
import { basename, join } from 'node:path'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
16
|
+
import { WebSocket, WebSocketServer } from 'ws'
|
|
17
|
+
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
+
import type { SshEngine, ShellSession } from './engine.ts'
|
|
19
|
+
import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
|
|
20
|
+
import type { HostStore } from './store.ts'
|
|
21
|
+
|
|
22
|
+
/** Cap on JSON request bodies (host entries and exec payloads are small). */
|
|
23
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024
|
|
24
|
+
|
|
25
|
+
/** Cap on declared upload bodies (staged to disk before SFTP). */
|
|
26
|
+
const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One noServer WebSocket server for terminal upgrades: the browser half uses
|
|
30
|
+
* a standards-compliant WebSocket, so the host must speak real RFC 6455
|
|
31
|
+
* frames (the webserver hands us the raw upgraded socket).
|
|
32
|
+
*/
|
|
33
|
+
const terminalWss = new WebSocketServer({ noServer: true })
|
|
34
|
+
|
|
35
|
+
/** Pause the shell when the socket's send buffer exceeds this… */
|
|
36
|
+
const BACKPRESSURE_HIGH_WATER = 1024 * 1024
|
|
37
|
+
|
|
38
|
+
/** …and resume once it drains below this. */
|
|
39
|
+
const BACKPRESSURE_LOW_WATER = 512 * 1024
|
|
40
|
+
|
|
41
|
+
/** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
|
|
42
|
+
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
43
|
+
const address = request.socket.remoteAddress
|
|
44
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
45
|
+
const host = request.headers.host
|
|
46
|
+
if (typeof host !== 'string') return false
|
|
47
|
+
let hostUrl: URL
|
|
48
|
+
try {
|
|
49
|
+
hostUrl = new URL(`http://${host}`)
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
54
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
55
|
+
const origin = request.headers.origin
|
|
56
|
+
if (origin === undefined) return true
|
|
57
|
+
try {
|
|
58
|
+
return new URL(origin).host === hostUrl.host
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One JSON response. */
|
|
65
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
66
|
+
const payload = JSON.stringify(body)
|
|
67
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
68
|
+
res.end(payload)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read a JSON request body (undefined when too large or unparseable). */
|
|
72
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
|
|
73
|
+
const chunks: Buffer[] = []
|
|
74
|
+
let size = 0
|
|
75
|
+
for await (const chunk of req) {
|
|
76
|
+
const buffer = chunk as Buffer
|
|
77
|
+
size += buffer.length
|
|
78
|
+
if (size > MAX_JSON_BODY_BYTES) return undefined
|
|
79
|
+
chunks.push(buffer)
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
83
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** URL query helper (first value, decoded). */
|
|
90
|
+
function queryParam(url: URL, name: string): string | undefined {
|
|
91
|
+
const value = url.searchParams.get(name)
|
|
92
|
+
return value === null ? undefined : value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Route family dependencies. */
|
|
96
|
+
export interface SshRoutesDeps {
|
|
97
|
+
/** The host store (CRUD). */
|
|
98
|
+
store: HostStore
|
|
99
|
+
/** The engine (ops). */
|
|
100
|
+
engine: SshEngine
|
|
101
|
+
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
102
|
+
stagingDir?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
|
|
107
|
+
* @param deps - store, engine, staging dir.
|
|
108
|
+
* @returns routes and the upgrade route.
|
|
109
|
+
*/
|
|
110
|
+
export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
|
|
111
|
+
const { store, engine } = deps
|
|
112
|
+
const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
|
|
113
|
+
// The upload route stages request bodies here; it must exist before the
|
|
114
|
+
// first request (a missing dir would hang the first upload forever).
|
|
115
|
+
mkdirSync(staging, { recursive: true })
|
|
116
|
+
|
|
117
|
+
/** Guard helper: fence + method check. */
|
|
118
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
119
|
+
if (!isLoopbackRequest(req)) {
|
|
120
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
if (req.method !== method) {
|
|
124
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const routes: WebRoute[] = [
|
|
131
|
+
// ------------------------------------------------------------ hosts
|
|
132
|
+
{
|
|
133
|
+
kind: 'exact',
|
|
134
|
+
path: SSH_API.hosts,
|
|
135
|
+
handler: async (req, res) => {
|
|
136
|
+
// One handler per path (the webserver keyed route registry rejects
|
|
137
|
+
// duplicate (kind, path)); dispatch by HTTP method here.
|
|
138
|
+
const method = req.method ?? 'GET'
|
|
139
|
+
if (!isLoopbackRequest(req)) {
|
|
140
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
144
|
+
if (method === 'GET') {
|
|
145
|
+
writeJson(res, 200, { hosts: engine.list(queryParam(url, 'query')) })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (method === 'POST') {
|
|
149
|
+
const body = await readJsonBody(req)
|
|
150
|
+
if (body === undefined) {
|
|
151
|
+
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const entry = store.create(body as unknown as HostPayload)
|
|
156
|
+
writeJson(res, 201, { host: store.summarize(entry) })
|
|
157
|
+
} catch (error) {
|
|
158
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (method !== 'PATCH' && method !== 'DELETE') {
|
|
163
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` })
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const alias = queryParam(url, 'alias')
|
|
167
|
+
if (alias === undefined || alias === '') {
|
|
168
|
+
writeJson(res, 400, { error: 'alias query parameter is required' })
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (method === 'PATCH') {
|
|
172
|
+
const body = await readJsonBody(req)
|
|
173
|
+
if (body === undefined) {
|
|
174
|
+
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const entry = store.update(alias, body as unknown as Partial<HostPayload>)
|
|
179
|
+
writeJson(res, 200, { host: store.summarize(entry) })
|
|
180
|
+
} catch (error) {
|
|
181
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
182
|
+
}
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (method === 'DELETE') {
|
|
186
|
+
try {
|
|
187
|
+
engine.stopAllTunnels(alias)
|
|
188
|
+
store.delete(alias)
|
|
189
|
+
writeJson(res, 200, { ok: true })
|
|
190
|
+
} catch (error) {
|
|
191
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
192
|
+
}
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` })
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
kind: 'exact',
|
|
200
|
+
path: SSH_API.importSshConfig,
|
|
201
|
+
handler: async (req, res) => {
|
|
202
|
+
if (!guard(req, res, 'POST')) return
|
|
203
|
+
try {
|
|
204
|
+
writeJson(res, 200, { result: store.importFromSshConfig() })
|
|
205
|
+
} catch (error) {
|
|
206
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
// ------------------------------------------------------------ ops
|
|
211
|
+
{
|
|
212
|
+
kind: 'exact',
|
|
213
|
+
path: SSH_API.test,
|
|
214
|
+
handler: async (req, res) => {
|
|
215
|
+
if (!guard(req, res, 'POST')) return
|
|
216
|
+
const body = await readJsonBody(req)
|
|
217
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
218
|
+
if (alias === '') {
|
|
219
|
+
writeJson(res, 400, { error: 'alias is required' })
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
writeJson(res, 200, { result: await engine.test(alias) })
|
|
224
|
+
} catch (error) {
|
|
225
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
kind: 'exact',
|
|
231
|
+
path: SSH_API.exec,
|
|
232
|
+
handler: async (req, res) => {
|
|
233
|
+
if (!guard(req, res, 'POST')) return
|
|
234
|
+
const body = await readJsonBody(req)
|
|
235
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
236
|
+
const command = typeof body?.command === 'string' ? body.command : ''
|
|
237
|
+
if (alias === '' || command === '') {
|
|
238
|
+
writeJson(res, 400, { error: 'alias and command are required' })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
|
|
242
|
+
try {
|
|
243
|
+
writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) })
|
|
244
|
+
} catch (error) {
|
|
245
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
kind: 'exact',
|
|
251
|
+
path: SSH_API.cluster,
|
|
252
|
+
handler: async (req, res) => {
|
|
253
|
+
if (!guard(req, res, 'POST')) return
|
|
254
|
+
const body = await readJsonBody(req)
|
|
255
|
+
const command = typeof body?.command === 'string' ? body.command : ''
|
|
256
|
+
if (command === '') {
|
|
257
|
+
writeJson(res, 400, { error: 'command is required' })
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x): x is string => typeof x === 'string') : undefined
|
|
261
|
+
const tags = Array.isArray(body?.tags) ? body.tags.filter((x): x is string => typeof x === 'string') : undefined
|
|
262
|
+
const environment = typeof body?.environment === 'string' ? body.environment : undefined
|
|
263
|
+
const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
|
|
264
|
+
const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
|
|
265
|
+
try {
|
|
266
|
+
writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers }) })
|
|
267
|
+
} catch (error) {
|
|
268
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
// ------------------------------------------------------------- ls
|
|
273
|
+
{
|
|
274
|
+
kind: 'exact',
|
|
275
|
+
path: SSH_API.ls,
|
|
276
|
+
handler: async (req, res) => {
|
|
277
|
+
if (!guard(req, res, 'GET')) return
|
|
278
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
279
|
+
const alias = queryParam(url, 'alias')
|
|
280
|
+
const path = queryParam(url, 'path') ?? '/'
|
|
281
|
+
if (alias === undefined || alias === '') {
|
|
282
|
+
writeJson(res, 400, { error: 'alias query parameter is required' })
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
writeJson(res, 200, { entries: await engine.ls(alias, path) })
|
|
287
|
+
} catch (error) {
|
|
288
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
// --------------------------------------------------------- tunnel
|
|
293
|
+
{
|
|
294
|
+
kind: 'exact',
|
|
295
|
+
path: SSH_API.tunnel,
|
|
296
|
+
handler: async (req, res) => {
|
|
297
|
+
if (!guard(req, res, 'POST')) return
|
|
298
|
+
const body = await readJsonBody(req)
|
|
299
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
300
|
+
if (action === 'list') {
|
|
301
|
+
writeJson(res, 200, { tunnels: engine.listTunnels() })
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
if (action === 'start') {
|
|
305
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
306
|
+
const remotePort = typeof body?.remotePort === 'number' ? body.remotePort : undefined
|
|
307
|
+
if (alias === '' || remotePort === undefined) {
|
|
308
|
+
writeJson(res, 400, { error: 'alias and remotePort are required' })
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const tunnel = await engine.startTunnel(alias, {
|
|
313
|
+
remotePort,
|
|
314
|
+
remoteHost: typeof body?.remoteHost === 'string' && body.remoteHost !== '' ? body.remoteHost : undefined,
|
|
315
|
+
localPort: typeof body?.localPort === 'number' ? body.localPort : undefined,
|
|
316
|
+
})
|
|
317
|
+
writeJson(res, 200, { tunnel })
|
|
318
|
+
} catch (error) {
|
|
319
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
320
|
+
}
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (action === 'stop') {
|
|
324
|
+
const id = typeof body?.tunnelId === 'string' ? body.tunnelId : ''
|
|
325
|
+
if (id === '') {
|
|
326
|
+
writeJson(res, 400, { error: 'tunnelId is required' })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
writeJson(res, 200, { ok: engine.stopTunnel(id) })
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
if (action === 'stop-all') {
|
|
333
|
+
const alias = typeof body?.alias === 'string' ? body.alias : undefined
|
|
334
|
+
writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === '' ? undefined : alias) })
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
writeJson(res, 400, { error: `unknown action '${action}'` })
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
// --------------------------------------------------------- upload
|
|
341
|
+
{
|
|
342
|
+
kind: 'exact',
|
|
343
|
+
path: SSH_API.upload,
|
|
344
|
+
handler: async (req, res) => {
|
|
345
|
+
if (!guard(req, res, 'POST')) return
|
|
346
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
347
|
+
const alias = queryParam(url, 'alias')
|
|
348
|
+
const remotePath = queryParam(url, 'remotePath')
|
|
349
|
+
if (alias === undefined || remotePath === undefined) {
|
|
350
|
+
writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
const declared = Number(req.headers['content-length'])
|
|
354
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
|
|
355
|
+
writeJson(res, 413, { error: 'upload body too large' })
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
res.writeHead(200, {
|
|
359
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
360
|
+
'cache-control': 'no-cache',
|
|
361
|
+
'referrer-policy': 'no-referrer',
|
|
362
|
+
})
|
|
363
|
+
const emit = (line: unknown): void => {
|
|
364
|
+
try { res.write(JSON.stringify(line) + '\n') } catch { /* client gone */ }
|
|
365
|
+
}
|
|
366
|
+
// Stage the uploaded bytes, then SFTP them out with progress frames.
|
|
367
|
+
const tmp = join(staging, `upload-${randomBytes(6).toString('hex')}`)
|
|
368
|
+
const sink = createWriteStream(tmp)
|
|
369
|
+
let settled = false
|
|
370
|
+
// Every terminal path (sink error, client abort, response loss) must
|
|
371
|
+
// emit a result frame, end the response, and remove the tmp file.
|
|
372
|
+
const fail = (error: unknown): void => {
|
|
373
|
+
if (settled) return
|
|
374
|
+
settled = true
|
|
375
|
+
emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
376
|
+
try { sink.destroy() } catch { /* closed */ }
|
|
377
|
+
void unlink(tmp).catch(() => undefined)
|
|
378
|
+
try { res.end() } catch { /* closed */ }
|
|
379
|
+
}
|
|
380
|
+
const done = (): void => {
|
|
381
|
+
if (settled) return
|
|
382
|
+
settled = true
|
|
383
|
+
try { res.end() } catch { /* closed */ }
|
|
384
|
+
}
|
|
385
|
+
sink.on('error', (error) => fail(error))
|
|
386
|
+
req.on('error', (error) => fail(error))
|
|
387
|
+
req.on('aborted', () => fail('upload aborted by the client'))
|
|
388
|
+
res.on('error', () => fail('response stream closed'))
|
|
389
|
+
res.on('close', () => { if (!res.writableEnded) fail('connection closed') })
|
|
390
|
+
req.pipe(sink)
|
|
391
|
+
sink.on('finish', async () => {
|
|
392
|
+
if (settled) return
|
|
393
|
+
emit({ type: 'progress', progress: { phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 } })
|
|
394
|
+
try {
|
|
395
|
+
const outcome = await engine.upload(alias, tmp, remotePath, false, progress => emit({ type: 'progress', progress }))
|
|
396
|
+
emit({ type: 'result', ok: true, transferredBytes: outcome.bytes })
|
|
397
|
+
} catch (error) {
|
|
398
|
+
emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
399
|
+
} finally {
|
|
400
|
+
await unlink(tmp).catch(() => undefined)
|
|
401
|
+
done()
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
// ------------------------------------------------------- download
|
|
407
|
+
{
|
|
408
|
+
kind: 'exact',
|
|
409
|
+
path: SSH_API.download,
|
|
410
|
+
handler: async (req, res) => {
|
|
411
|
+
if (!guard(req, res, 'GET')) return
|
|
412
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
413
|
+
const alias = queryParam(url, 'alias')
|
|
414
|
+
const remotePath = queryParam(url, 'remotePath')
|
|
415
|
+
if (alias === undefined || remotePath === undefined) {
|
|
416
|
+
writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
|
|
420
|
+
try {
|
|
421
|
+
const outcome = await engine.download(alias, remotePath, tmp)
|
|
422
|
+
res.writeHead(200, {
|
|
423
|
+
'content-type': 'application/octet-stream',
|
|
424
|
+
'content-length': String(outcome.bytes),
|
|
425
|
+
'content-disposition': `attachment; filename="${basename(remotePath).replace(/"/g, '')}"`,
|
|
426
|
+
'referrer-policy': 'no-referrer',
|
|
427
|
+
})
|
|
428
|
+
await new Promise<void>((resolve, reject) => {
|
|
429
|
+
const source = createReadStream(tmp)
|
|
430
|
+
source.on('error', reject)
|
|
431
|
+
res.on('error', reject)
|
|
432
|
+
source.pipe(res)
|
|
433
|
+
source.on('end', resolve)
|
|
434
|
+
})
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (!res.headersSent) {
|
|
437
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
438
|
+
} else {
|
|
439
|
+
// Mid-stream failure after headers: destroy so the browser does
|
|
440
|
+
// not hang waiting for the promised content-length bytes.
|
|
441
|
+
res.destroy()
|
|
442
|
+
}
|
|
443
|
+
} finally {
|
|
444
|
+
await unlink(tmp).catch(() => undefined)
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------- terminal (upgrade)
|
|
451
|
+
const upgrade: WebUpgradeRoute = {
|
|
452
|
+
path: SSH_API.terminal,
|
|
453
|
+
handler: (req, socket, head) => {
|
|
454
|
+
if (!isLoopbackRequest(req)) {
|
|
455
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
456
|
+
socket.destroy()
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
460
|
+
const alias = queryParam(url, 'alias')
|
|
461
|
+
if (alias === undefined) {
|
|
462
|
+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
|
463
|
+
socket.destroy()
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
const cols = Number.parseInt(queryParam(url, 'cols') ?? '80', 10)
|
|
467
|
+
const rows = Number.parseInt(queryParam(url, 'rows') ?? '24', 10)
|
|
468
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
469
|
+
let session: ShellSession | undefined
|
|
470
|
+
let closed = false
|
|
471
|
+
let paused = false
|
|
472
|
+
// Resume the shell once the socket's send buffer drains below the
|
|
473
|
+
// low-water mark (transport backpressure).
|
|
474
|
+
const resume = (): void => {
|
|
475
|
+
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
|
|
476
|
+
paused = false
|
|
477
|
+
session?.resume()
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const sendFrame = (frame: TerminalServerFrame): void => {
|
|
481
|
+
if (closed || ws.readyState !== WebSocket.OPEN) return
|
|
482
|
+
ws.send(JSON.stringify(frame), resume)
|
|
483
|
+
if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
|
|
484
|
+
paused = true
|
|
485
|
+
session?.pause()
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const closeSession = (): void => {
|
|
489
|
+
const opened = session
|
|
490
|
+
session = undefined
|
|
491
|
+
if (opened !== undefined) opened.close()
|
|
492
|
+
}
|
|
493
|
+
engine.openShell(alias, {
|
|
494
|
+
cols: Number.isFinite(cols) ? cols : 80,
|
|
495
|
+
rows: Number.isFinite(rows) ? rows : 24,
|
|
496
|
+
}).then((opened) => {
|
|
497
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
498
|
+
opened.close()
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
session = opened
|
|
502
|
+
sendFrame({ type: 'ready', alias })
|
|
503
|
+
opened.onData = (data) => sendFrame({ type: 'output', data: data.toString('utf8') })
|
|
504
|
+
opened.onExit = (code, error) => {
|
|
505
|
+
sendFrame({ type: 'exit', code, error })
|
|
506
|
+
closed = true
|
|
507
|
+
try { ws.close(1000) } catch { /* already closed */ }
|
|
508
|
+
}
|
|
509
|
+
}).catch((error) => {
|
|
510
|
+
sendFrame({ type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
|
|
511
|
+
closed = true
|
|
512
|
+
try { ws.close(1000) } catch { /* already closed */ }
|
|
513
|
+
})
|
|
514
|
+
ws.on('message', (data) => {
|
|
515
|
+
let frame: TerminalClientFrame
|
|
516
|
+
try {
|
|
517
|
+
frame = JSON.parse(String(data)) as TerminalClientFrame
|
|
518
|
+
} catch {
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
if (frame.type === 'input') {
|
|
522
|
+
session?.send(frame.data)
|
|
523
|
+
} else if (frame.type === 'resize') {
|
|
524
|
+
session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
ws.on('close', () => {
|
|
528
|
+
closed = true
|
|
529
|
+
closeSession()
|
|
530
|
+
})
|
|
531
|
+
ws.on('error', () => {
|
|
532
|
+
closed = true
|
|
533
|
+
closeSession()
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return { routes, upgrade }
|
|
540
|
+
}
|