@tiphareth/dsh-hardssh 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/ssh/routes.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'
|
|
11
11
|
import { unlink } from 'node:fs/promises'
|
|
12
12
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
13
|
+
import { pipeline } from 'node:stream/promises'
|
|
13
14
|
import { basename, join } from 'node:path'
|
|
14
15
|
import { tmpdir } from 'node:os'
|
|
15
16
|
import { randomBytes } from 'node:crypto'
|
|
@@ -20,6 +21,10 @@ import { SSH_API, type HostPayload, type ApiErrorBody, type TerminalClientFrame,
|
|
|
20
21
|
import type { HostStore } from './store.ts'
|
|
21
22
|
import { HostKeyMismatchError, HostKeyUnknownError, type KnownHostsStore } from './known-hosts.ts'
|
|
22
23
|
import { NeedsPasswordError } from './engine.ts'
|
|
24
|
+
import { MIN_MASTER_PASSWORD_LENGTH } from './vault.ts'
|
|
25
|
+
// Shared host HTTP boundary (A-05): one loopback fence, one JSON body reader,
|
|
26
|
+
// one JSON writer for every host route family — copies drift apart silently.
|
|
27
|
+
import { isLoopbackRequest, queryParam, readJsonBody as readJsonBodyResult, writeJson } from '../host-http.ts'
|
|
23
28
|
|
|
24
29
|
/** Cap on JSON request bodies (host entries and exec payloads are small). */
|
|
25
30
|
const MAX_JSON_BODY_BYTES = 64 * 1024
|
|
@@ -27,13 +32,6 @@ const MAX_JSON_BODY_BYTES = 64 * 1024
|
|
|
27
32
|
/** Cap on declared upload bodies (staged to disk before SFTP). */
|
|
28
33
|
export const DEFAULT_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
|
|
29
34
|
|
|
30
|
-
/**
|
|
31
|
-
* One noServer WebSocket server for terminal upgrades: the browser half uses
|
|
32
|
-
* a standards-compliant WebSocket, so the host must speak real RFC 6455
|
|
33
|
-
* frames (the webserver hands us the raw upgraded socket).
|
|
34
|
-
*/
|
|
35
|
-
const terminalWss = new WebSocketServer({ noServer: true })
|
|
36
|
-
|
|
37
35
|
/** Pause the shell when the socket's send buffer exceeds this… */
|
|
38
36
|
const BACKPRESSURE_HIGH_WATER = 1024 * 1024
|
|
39
37
|
|
|
@@ -45,10 +43,67 @@ type StageUploadResult =
|
|
|
45
43
|
| { ok: true; receivedBytes: number }
|
|
46
44
|
| { ok: false; reason: 'too-large' | 'aborted' | 'request-error' | 'write-error'; receivedBytes: number; error: Error }
|
|
47
45
|
|
|
46
|
+
/**
|
|
47
|
+
* One live terminal upgrade. The session is captured only once openShell
|
|
48
|
+
* resolves; `pending` lets teardown close a shell that is still opening.
|
|
49
|
+
*/
|
|
50
|
+
interface TerminalEntry {
|
|
51
|
+
ws: WebSocket
|
|
52
|
+
session: ShellSession | undefined
|
|
53
|
+
pending: Promise<ShellSession>
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
function toError(error: unknown): Error {
|
|
49
57
|
return error instanceof Error ? error : new Error(String(error))
|
|
50
58
|
}
|
|
51
59
|
|
|
60
|
+
/** Abort one HTTP operation when its caller disconnects. */
|
|
61
|
+
function requestAbort(req: IncomingMessage, res: ServerResponse): { signal: AbortSignal; dispose: () => void } {
|
|
62
|
+
const controller = new AbortController()
|
|
63
|
+
const abort = (message: string): void => {
|
|
64
|
+
if (controller.signal.aborted) return
|
|
65
|
+
controller.abort(Object.assign(new Error(message), { name: 'AbortError' }))
|
|
66
|
+
}
|
|
67
|
+
const onAborted = (): void => { abort('request aborted by the client') }
|
|
68
|
+
const onRequestClose = (): void => {
|
|
69
|
+
if (!req.complete) abort('request connection closed before completion')
|
|
70
|
+
}
|
|
71
|
+
const onResponseClose = (): void => {
|
|
72
|
+
if (!res.writableFinished) abort('response connection closed before completion')
|
|
73
|
+
}
|
|
74
|
+
req.once('aborted', onAborted)
|
|
75
|
+
req.once('close', onRequestClose)
|
|
76
|
+
res.once('close', onResponseClose)
|
|
77
|
+
return {
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
dispose: () => {
|
|
80
|
+
req.off('aborted', onAborted)
|
|
81
|
+
req.off('close', onRequestClose)
|
|
82
|
+
res.off('close', onResponseClose)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Strict runtime decoder for client terminal frames. */
|
|
88
|
+
function terminalClientFrame(value: unknown): TerminalClientFrame | undefined {
|
|
89
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
90
|
+
const frame = value as Record<string, unknown>
|
|
91
|
+
if (frame.type === 'input') {
|
|
92
|
+
if (typeof frame.data !== 'string') return undefined
|
|
93
|
+
if (Object.keys(frame).some(key => key !== 'type' && key !== 'data')) return undefined
|
|
94
|
+
return { type: 'input', data: frame.data }
|
|
95
|
+
}
|
|
96
|
+
if (frame.type === 'resize') {
|
|
97
|
+
if (!Number.isSafeInteger(frame.cols) || !Number.isSafeInteger(frame.rows)) return undefined
|
|
98
|
+
const cols = frame.cols as number
|
|
99
|
+
const rows = frame.rows as number
|
|
100
|
+
if (cols < 2 || cols > 1_000 || rows < 1 || rows > 1_000) return undefined
|
|
101
|
+
if (Object.keys(frame).some(key => key !== 'type' && key !== 'cols' && key !== 'rows')) return undefined
|
|
102
|
+
return { type: 'resize', cols, rows }
|
|
103
|
+
}
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
52
107
|
/** Idempotent staging-file cleanup (ENOENT is fine; other errors surface). */
|
|
53
108
|
async function removeStagingFile(path: string): Promise<void> {
|
|
54
109
|
try {
|
|
@@ -162,73 +217,40 @@ function rejectUploadTooLarge(req: IncomingMessage, res: ServerResponse): void {
|
|
|
162
217
|
})
|
|
163
218
|
}
|
|
164
219
|
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const origin = request.headers.origin
|
|
180
|
-
if (origin === undefined) return true
|
|
181
|
-
try {
|
|
182
|
-
return new URL(origin).host === hostUrl.host
|
|
183
|
-
} catch {
|
|
184
|
-
return false
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** One JSON response. */
|
|
189
|
-
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
190
|
-
const payload = JSON.stringify(body)
|
|
191
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
192
|
-
res.end(payload)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Map a thrown error to a structured ApiErrorBody (host-key errors carry
|
|
196
|
-
* machine codes + fingerprints for the GUI's confirm dialog). */
|
|
197
|
-
function errorBody(error: unknown): ApiErrorBody {
|
|
220
|
+
/**
|
|
221
|
+
* THE error mapper for the SSH route family (B-14): one status policy and one
|
|
222
|
+
* body shape everywhere, so identical auth/TOFU failures are recognizable by
|
|
223
|
+
* status + code no matter which endpoint produced them.
|
|
224
|
+
*
|
|
225
|
+
* Status policy (a single status per code, never 2xx):
|
|
226
|
+
* - 500 — interactive gates (NEEDS_PASSWORD / HOST_KEY_UNKNOWN /
|
|
227
|
+
* HOST_KEY_MISMATCH), so a client's `readJson` on 2xx can never mis-parse a
|
|
228
|
+
* structured error as a successful result — and every other failure.
|
|
229
|
+
* - 400/404/409/413/423/429 are reserved for the request/vault-specific
|
|
230
|
+
* checks each route performs BEFORE calling the engine.
|
|
231
|
+
*/
|
|
232
|
+
function errorBody(error: unknown, redact?: (text: string) => string): ApiErrorBody {
|
|
233
|
+
const safe = (text: string): string => redact?.(text) ?? text
|
|
198
234
|
if (error instanceof HostKeyUnknownError) {
|
|
199
|
-
return { error: error.message, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 }
|
|
235
|
+
return { error: safe(error.message), code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 }
|
|
200
236
|
}
|
|
201
237
|
if (error instanceof HostKeyMismatchError) {
|
|
202
|
-
return { error: error.message, code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual, hostKeyMismatch: { expected: error.expected, actual: error.actual } }
|
|
238
|
+
return { error: safe(error.message), code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual, hostKeyMismatch: { expected: error.expected, actual: error.actual } }
|
|
203
239
|
}
|
|
204
240
|
if (error instanceof NeedsPasswordError) {
|
|
205
|
-
return { error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret }
|
|
241
|
+
return { error: safe(error.message), code: 'NEEDS_PASSWORD', secret: error.secret }
|
|
206
242
|
}
|
|
207
|
-
return { error: error instanceof Error ? error.message : String(error) }
|
|
243
|
+
return { error: safe(error instanceof Error ? error.message : String(error)) }
|
|
208
244
|
}
|
|
209
245
|
|
|
210
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* Read a JSON request body, collapsing every failure (too large, malformed,
|
|
248
|
+
* non-object) to `undefined` — the call sites only distinguish "no usable
|
|
249
|
+
* body" and keep their own 400 text, matching the historical local reader.
|
|
250
|
+
*/
|
|
211
251
|
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
for await (const chunk of req) {
|
|
215
|
-
const buffer = chunk as Buffer
|
|
216
|
-
size += buffer.length
|
|
217
|
-
if (size > MAX_JSON_BODY_BYTES) return undefined
|
|
218
|
-
chunks.push(buffer)
|
|
219
|
-
}
|
|
220
|
-
try {
|
|
221
|
-
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
222
|
-
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
223
|
-
} catch {
|
|
224
|
-
return undefined
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** URL query helper (first value, decoded). */
|
|
229
|
-
function queryParam(url: URL, name: string): string | undefined {
|
|
230
|
-
const value = url.searchParams.get(name)
|
|
231
|
-
return value === null ? undefined : value
|
|
252
|
+
const result = await readJsonBodyResult(req, MAX_JSON_BODY_BYTES)
|
|
253
|
+
return result.ok ? result.body : undefined
|
|
232
254
|
}
|
|
233
255
|
|
|
234
256
|
/** Route family dependencies. */
|
|
@@ -241,8 +263,8 @@ export interface SshRoutesDeps {
|
|
|
241
263
|
knownHosts?: KnownHostsStore
|
|
242
264
|
/** Credential vault (optional; enables /api/dsh-ssh/vault endpoints). */
|
|
243
265
|
vault?: import('./vault.ts').Vault
|
|
244
|
-
/** SSH-bound workspace
|
|
245
|
-
ledger?: import('../
|
|
266
|
+
/** SSH-bound workspace record source (host-delete reference check). */
|
|
267
|
+
ledger?: import('../backend.ts').WorkspaceStoreView
|
|
246
268
|
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
247
269
|
stagingDir?: string
|
|
248
270
|
/** Hard cap on ACTUAL upload bytes accepted (chunked requests included).
|
|
@@ -253,13 +275,30 @@ export interface SshRoutesDeps {
|
|
|
253
275
|
onHostInvalidated?: (alias: string) => void
|
|
254
276
|
}
|
|
255
277
|
|
|
278
|
+
/** Every /api/dsh-ssh route plus the terminal upgrade, owned by one call. */
|
|
279
|
+
export interface SshRoutes {
|
|
280
|
+
routes: WebRoute[]
|
|
281
|
+
upgrade: WebUpgradeRoute
|
|
282
|
+
/**
|
|
283
|
+
* Release this instance's terminal resources: refuse new upgrades, close
|
|
284
|
+
* every live terminal session (and its SSH shell), close the WebSocketServer.
|
|
285
|
+
* Idempotent — a second call resolves without touching anything.
|
|
286
|
+
*/
|
|
287
|
+
dispose: () => Promise<void>
|
|
288
|
+
}
|
|
289
|
+
|
|
256
290
|
/**
|
|
257
291
|
* Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
|
|
258
292
|
* @param deps - store, engine, staging dir.
|
|
259
|
-
* @returns routes
|
|
293
|
+
* @returns routes, the upgrade route, and this instance's disposer.
|
|
260
294
|
*/
|
|
261
|
-
export function makeRoutes(deps: SshRoutesDeps):
|
|
295
|
+
export function makeRoutes(deps: SshRoutesDeps): SshRoutes {
|
|
262
296
|
const { store, engine } = deps
|
|
297
|
+
/** Every route error string passes the engine leak guard. Tolerates engine
|
|
298
|
+
* doubles that predate the redactor (tests inject minimal fakes). */
|
|
299
|
+
const redact = typeof engine.redact === 'function' ? (text: string): string => engine.redact(text) : (text: string): string => text
|
|
300
|
+
const safeMessage = (error: unknown): string => redact(error instanceof Error ? error.message : String(error))
|
|
301
|
+
const safeErrorBody = (error: unknown): ApiErrorBody => errorBody(error, redact)
|
|
263
302
|
const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
|
|
264
303
|
const uploadLimitBytes = deps.uploadLimitBytes ?? DEFAULT_MAX_UPLOAD_BYTES
|
|
265
304
|
if (!Number.isSafeInteger(uploadLimitBytes) || uploadLimitBytes < 0) {
|
|
@@ -269,6 +308,45 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
269
308
|
// first request (a missing dir would hang the first upload forever).
|
|
270
309
|
mkdirSync(staging, { recursive: true })
|
|
271
310
|
|
|
311
|
+
// ------------------------------------------------- terminal (upgrade)
|
|
312
|
+
// Instance-owned, NOT module-level: a module-level server would outlive its
|
|
313
|
+
// plugin instance, so HMR/re-apply would leave the old terminal sockets and
|
|
314
|
+
// SSH shells alive with no owner able to close them (A-06).
|
|
315
|
+
const terminalWss = new WebSocketServer({ noServer: true })
|
|
316
|
+
/** Live terminals so teardown can close sessions the sockets still own. */
|
|
317
|
+
const terminals = new Set<TerminalEntry>()
|
|
318
|
+
let disposed = false
|
|
319
|
+
let disposePromise: Promise<void> | undefined
|
|
320
|
+
|
|
321
|
+
const dispose = (): Promise<void> => {
|
|
322
|
+
disposePromise ??= (async (): Promise<void> => {
|
|
323
|
+
disposed = true
|
|
324
|
+
const live = [...terminals]
|
|
325
|
+
// A session may still be opening (openShell in flight): close it as
|
|
326
|
+
// soon as it lands so teardown does not leave an orphan SSH shell.
|
|
327
|
+
const opening = await Promise.allSettled(live.map(entry => entry.pending))
|
|
328
|
+
for (const settled of opening) {
|
|
329
|
+
if (settled.status === 'fulfilled') settled.value.close()
|
|
330
|
+
}
|
|
331
|
+
for (const entry of live) {
|
|
332
|
+
const session = entry.session
|
|
333
|
+
entry.session = undefined
|
|
334
|
+
if (session !== undefined) session.close()
|
|
335
|
+
const ws = entry.ws
|
|
336
|
+
try {
|
|
337
|
+
if (ws.readyState === WebSocket.OPEN) ws.close(1001, 'terminal server disposed')
|
|
338
|
+
else if (ws.readyState !== WebSocket.CLOSED) ws.terminate()
|
|
339
|
+
} catch {
|
|
340
|
+
// Socket already gone: nothing left to close.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
await new Promise<void>((resolve) => {
|
|
344
|
+
terminalWss.close(() => resolve())
|
|
345
|
+
})
|
|
346
|
+
})()
|
|
347
|
+
return disposePromise
|
|
348
|
+
}
|
|
349
|
+
|
|
272
350
|
/** Guard helper: fence + method check. */
|
|
273
351
|
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
274
352
|
if (!isLoopbackRequest(req)) {
|
|
@@ -310,7 +388,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
310
388
|
const entry = await store.create(body as unknown as HostPayload)
|
|
311
389
|
writeJson(res, 201, { host: store.summarize(entry) })
|
|
312
390
|
} catch (error) {
|
|
313
|
-
writeJson(res, 400, { error:
|
|
391
|
+
writeJson(res, 400, { error: safeMessage(error) })
|
|
314
392
|
}
|
|
315
393
|
return
|
|
316
394
|
}
|
|
@@ -340,7 +418,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
340
418
|
deps.onHostInvalidated?.(alias)
|
|
341
419
|
writeJson(res, 200, { host: store.summarize(entry) })
|
|
342
420
|
} catch (error) {
|
|
343
|
-
writeJson(res, 400, { error:
|
|
421
|
+
writeJson(res, 400, { error: safeMessage(error) })
|
|
344
422
|
}
|
|
345
423
|
return
|
|
346
424
|
}
|
|
@@ -349,7 +427,20 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
349
427
|
// not be deleted (the workspaces would silently lose their
|
|
350
428
|
// transport). Return 409 with the referencing workspace titles.
|
|
351
429
|
if (deps.ledger !== undefined) {
|
|
352
|
-
|
|
430
|
+
let referenced: import('../protocol.ts').SshWorkspaceRecord[]
|
|
431
|
+
try {
|
|
432
|
+
referenced = (await deps.ledger.list()).filter(record => record.alias === alias)
|
|
433
|
+
} catch (error) {
|
|
434
|
+
// The generic workspace store can reject when the runtime failed
|
|
435
|
+
// to initialize (corrupt ledger). Deleting the host then could
|
|
436
|
+
// strand live SSH workspaces, so the guard refuses instead of
|
|
437
|
+
// guessing — fail closed.
|
|
438
|
+
writeJson(res, 409, {
|
|
439
|
+
error: `cannot verify references for alias '${alias}' because the workspace runtime is unavailable: ${safeMessage(error)}`,
|
|
440
|
+
code: 'HOST_IN_USE_GUARD_UNAVAILABLE',
|
|
441
|
+
})
|
|
442
|
+
return
|
|
443
|
+
}
|
|
353
444
|
if (referenced.length > 0) {
|
|
354
445
|
writeJson(res, 409, {
|
|
355
446
|
error: `alias '${alias}' is still referenced by ${referenced.length} SSH workspace(s) (${referenced.map(r => r.title).join('、')}) — delete those workspaces first`,
|
|
@@ -370,7 +461,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
370
461
|
deps.onHostInvalidated?.(alias)
|
|
371
462
|
writeJson(res, 200, { ok: true })
|
|
372
463
|
} catch (error) {
|
|
373
|
-
writeJson(res, 400, { error:
|
|
464
|
+
writeJson(res, 400, { error: safeMessage(error) })
|
|
374
465
|
}
|
|
375
466
|
return
|
|
376
467
|
}
|
|
@@ -383,9 +474,10 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
383
474
|
handler: async (req, res) => {
|
|
384
475
|
if (!guard(req, res, 'POST')) return
|
|
385
476
|
try {
|
|
386
|
-
|
|
477
|
+
const result = await store.importFromSshConfig()
|
|
478
|
+
writeJson(res, 200, { result })
|
|
387
479
|
} catch (error) {
|
|
388
|
-
writeJson(res, 500, { error:
|
|
480
|
+
writeJson(res, 500, { error: safeMessage(error) })
|
|
389
481
|
}
|
|
390
482
|
},
|
|
391
483
|
},
|
|
@@ -434,11 +526,14 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
434
526
|
return
|
|
435
527
|
}
|
|
436
528
|
try {
|
|
437
|
-
if (action === 'trust')
|
|
529
|
+
if (action === 'trust') {
|
|
530
|
+
const entry = store.find(alias)
|
|
531
|
+
knownHosts.trust(alias, entry === undefined ? undefined : { host: entry.host, port: entry.port })
|
|
532
|
+
}
|
|
438
533
|
else knownHosts.forget(alias)
|
|
439
534
|
writeJson(res, 200, { ok: true })
|
|
440
535
|
} catch (error) {
|
|
441
|
-
writeJson(res, 400, { error:
|
|
536
|
+
writeJson(res, 400, { error: safeMessage(error) })
|
|
442
537
|
}
|
|
443
538
|
},
|
|
444
539
|
},
|
|
@@ -474,24 +569,41 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
474
569
|
}
|
|
475
570
|
if (action === 'unlock') {
|
|
476
571
|
const password = typeof body?.password === 'string' ? body.password : ''
|
|
572
|
+
if (password.trim().length < MIN_MASTER_PASSWORD_LENGTH) {
|
|
573
|
+
writeJson(res, 400, { error: `vault password must contain at least ${MIN_MASTER_PASSWORD_LENGTH} non-whitespace characters` })
|
|
574
|
+
return
|
|
575
|
+
}
|
|
477
576
|
await vault.unlock(password)
|
|
478
577
|
writeJson(res, 200, { ok: true, status: vault.status() })
|
|
479
578
|
return
|
|
480
579
|
}
|
|
481
580
|
if (action === 'rekey') {
|
|
482
581
|
const password = typeof body?.password === 'string' ? body.password : ''
|
|
582
|
+
if (password.trim().length < MIN_MASTER_PASSWORD_LENGTH) {
|
|
583
|
+
writeJson(res, 400, { error: `vault password must contain at least ${MIN_MASTER_PASSWORD_LENGTH} non-whitespace characters` })
|
|
584
|
+
return
|
|
585
|
+
}
|
|
483
586
|
await vault.rekey(password)
|
|
484
587
|
writeJson(res, 200, { ok: true, status: vault.status() })
|
|
485
588
|
return
|
|
486
589
|
}
|
|
487
590
|
writeJson(res, 400, { error: 'action must be status|unlock|rekey' })
|
|
488
591
|
} catch (error) {
|
|
489
|
-
// Vault errors carry stable codes
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
592
|
+
// Vault errors carry stable codes + retry info; the HTTP status must
|
|
593
|
+
// distinguish a wrong password (401) from a lockout (429), a locked
|
|
594
|
+
// vault (423), a weak password (400) and an unexpected failure (500).
|
|
595
|
+
const record = error as { code?: unknown; remaining?: unknown; retryAfterMs?: unknown }
|
|
596
|
+
const code = typeof record.code === 'string' ? record.code : undefined
|
|
597
|
+
const status = code === 'VAULT_AUTH' ? 401
|
|
598
|
+
: code === 'VAULT_LOCKOUT' ? 429
|
|
599
|
+
: code === 'VAULT_LOCKED' ? 423
|
|
600
|
+
: code === 'VAULT_PASSWORD_WEAK' ? 400
|
|
601
|
+
: code === undefined ? 500 : 500
|
|
602
|
+
writeJson(res, status, {
|
|
603
|
+
error: safeMessage(error),
|
|
604
|
+
code,
|
|
605
|
+
...(typeof record.remaining === 'number' ? { remaining: record.remaining } : {}),
|
|
606
|
+
...(typeof record.retryAfterMs === 'number' ? { retryAfterMs: record.retryAfterMs } : {}),
|
|
495
607
|
})
|
|
496
608
|
}
|
|
497
609
|
},
|
|
@@ -545,25 +657,27 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
545
657
|
path: SSH_API.test,
|
|
546
658
|
handler: async (req, res) => {
|
|
547
659
|
if (!guard(req, res, 'POST')) return
|
|
548
|
-
const
|
|
549
|
-
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
550
|
-
if (alias === '') {
|
|
551
|
-
writeJson(res, 400, { error: 'alias is required' })
|
|
552
|
-
return
|
|
553
|
-
}
|
|
660
|
+
const request = requestAbort(req, res)
|
|
554
661
|
try {
|
|
555
|
-
|
|
662
|
+
const body = await readJsonBody(req)
|
|
663
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
664
|
+
if (alias === '') {
|
|
665
|
+
writeJson(res, 400, { error: 'alias is required' })
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
writeJson(res, 200, { result: await engine.test(alias, request.signal) })
|
|
556
669
|
} catch (error) {
|
|
557
670
|
if (error instanceof NeedsPasswordError) {
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
})
|
|
671
|
+
// The ONLY intentional 2xx difference: /test is a diagnostic the
|
|
672
|
+
// GUI polls, so a missing credential is reported as a result the
|
|
673
|
+
// user can act on. The body still comes from the shared mapper
|
|
674
|
+
// (same code/secret fields as every other route's 500).
|
|
675
|
+
writeJson(res, 200, { result: { ok: false, ...safeErrorBody(error) } })
|
|
564
676
|
return
|
|
565
677
|
}
|
|
566
|
-
writeJson(res, 500,
|
|
678
|
+
writeJson(res, 500, safeErrorBody(error))
|
|
679
|
+
} finally {
|
|
680
|
+
request.dispose()
|
|
567
681
|
}
|
|
568
682
|
},
|
|
569
683
|
},
|
|
@@ -572,18 +686,23 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
572
686
|
path: SSH_API.exec,
|
|
573
687
|
handler: async (req, res) => {
|
|
574
688
|
if (!guard(req, res, 'POST')) return
|
|
575
|
-
const
|
|
576
|
-
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
577
|
-
const command = typeof body?.command === 'string' ? body.command : ''
|
|
578
|
-
if (alias === '' || command === '') {
|
|
579
|
-
writeJson(res, 400, { error: 'alias and command are required' })
|
|
580
|
-
return
|
|
581
|
-
}
|
|
582
|
-
const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
|
|
689
|
+
const request = requestAbort(req, res)
|
|
583
690
|
try {
|
|
584
|
-
|
|
691
|
+
const body = await readJsonBody(req)
|
|
692
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
693
|
+
const command = typeof body?.command === 'string' ? body.command : ''
|
|
694
|
+
if (alias === '' || command === '') {
|
|
695
|
+
writeJson(res, 400, { error: 'alias and command are required' })
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
|
|
699
|
+
// A client disconnect aborts the REMOTE command instead of letting it
|
|
700
|
+
// run out its full budget on the server.
|
|
701
|
+
writeJson(res, 200, { result: await engine.exec(alias, command, { timeoutMs, signal: request.signal }) })
|
|
585
702
|
} catch (error) {
|
|
586
|
-
writeJson(res, 500,
|
|
703
|
+
writeJson(res, 500, safeErrorBody(error))
|
|
704
|
+
} finally {
|
|
705
|
+
request.dispose()
|
|
587
706
|
}
|
|
588
707
|
},
|
|
589
708
|
},
|
|
@@ -592,21 +711,26 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
592
711
|
path: SSH_API.cluster,
|
|
593
712
|
handler: async (req, res) => {
|
|
594
713
|
if (!guard(req, res, 'POST')) return
|
|
595
|
-
const
|
|
596
|
-
const command = typeof body?.command === 'string' ? body.command : ''
|
|
597
|
-
if (command === '') {
|
|
598
|
-
writeJson(res, 400, { error: 'command is required' })
|
|
599
|
-
return
|
|
600
|
-
}
|
|
601
|
-
const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x): x is string => typeof x === 'string') : undefined
|
|
602
|
-
const tags = Array.isArray(body?.tags) ? body.tags.filter((x): x is string => typeof x === 'string') : undefined
|
|
603
|
-
const environment = typeof body?.environment === 'string' ? body.environment : undefined
|
|
604
|
-
const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
|
|
605
|
-
const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
|
|
714
|
+
const request = requestAbort(req, res)
|
|
606
715
|
try {
|
|
607
|
-
|
|
716
|
+
const body = await readJsonBody(req)
|
|
717
|
+
const command = typeof body?.command === 'string' ? body.command : ''
|
|
718
|
+
if (command === '') {
|
|
719
|
+
writeJson(res, 400, { error: 'command is required' })
|
|
720
|
+
return
|
|
721
|
+
}
|
|
722
|
+
const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x): x is string => typeof x === 'string') : undefined
|
|
723
|
+
const tags = Array.isArray(body?.tags) ? body.tags.filter((x): x is string => typeof x === 'string') : undefined
|
|
724
|
+
const environment = typeof body?.environment === 'string' ? body.environment : undefined
|
|
725
|
+
const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
|
|
726
|
+
const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
|
|
727
|
+
writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers, signal: request.signal }) })
|
|
608
728
|
} catch (error) {
|
|
609
|
-
|
|
729
|
+
// Same mapper as /test and /exec: a failure to even start the run
|
|
730
|
+
// must carry the interactive code, not a flattened message.
|
|
731
|
+
writeJson(res, 500, safeErrorBody(error))
|
|
732
|
+
} finally {
|
|
733
|
+
request.dispose()
|
|
610
734
|
}
|
|
611
735
|
},
|
|
612
736
|
},
|
|
@@ -616,17 +740,29 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
616
740
|
path: SSH_API.ls,
|
|
617
741
|
handler: async (req, res) => {
|
|
618
742
|
if (!guard(req, res, 'GET')) return
|
|
619
|
-
const
|
|
620
|
-
const alias = queryParam(url, 'alias')
|
|
621
|
-
const path = queryParam(url, 'path') ?? '/'
|
|
622
|
-
if (alias === undefined || alias === '') {
|
|
623
|
-
writeJson(res, 400, { error: 'alias query parameter is required' })
|
|
624
|
-
return
|
|
625
|
-
}
|
|
743
|
+
const request = requestAbort(req, res)
|
|
626
744
|
try {
|
|
627
|
-
|
|
745
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
746
|
+
const alias = queryParam(url, 'alias')
|
|
747
|
+
const requested = queryParam(url, 'path') ?? '/'
|
|
748
|
+
if (alias === undefined || alias === '') {
|
|
749
|
+
writeJson(res, 400, { error: 'alias query parameter is required' })
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
// Same fence as the workspace listing route: an SFTP path is a POSIX
|
|
753
|
+
// absolute path, so NUL, backslashes and relative forms are rejected
|
|
754
|
+
// before they reach the remote filesystem.
|
|
755
|
+
if (requested.includes('\0') || requested.includes('\\') || !requested.startsWith('/')) {
|
|
756
|
+
writeJson(res, 400, { error: `path must be an absolute POSIX path (got '${requested}')` })
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
writeJson(res, 200, { entries: await engine.ls(alias, requested, request.signal) })
|
|
628
760
|
} catch (error) {
|
|
629
|
-
|
|
761
|
+
// Was a flattened 400 string, which lost NEEDS_PASSWORD / TOFU codes
|
|
762
|
+
// the GUI needs to prompt; the shared mapper (500) restores them.
|
|
763
|
+
writeJson(res, 500, safeErrorBody(error))
|
|
764
|
+
} finally {
|
|
765
|
+
request.dispose()
|
|
630
766
|
}
|
|
631
767
|
},
|
|
632
768
|
},
|
|
@@ -657,7 +793,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
657
793
|
})
|
|
658
794
|
writeJson(res, 200, { tunnel })
|
|
659
795
|
} catch (error) {
|
|
660
|
-
writeJson(res, 500, { error:
|
|
796
|
+
writeJson(res, 500, { error: safeMessage(error) })
|
|
661
797
|
}
|
|
662
798
|
return
|
|
663
799
|
}
|
|
@@ -697,11 +833,21 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
697
833
|
const contentLengthHeader = req.headers['content-length']
|
|
698
834
|
const declaredBytes = typeof contentLengthHeader === 'string' ? Number(contentLengthHeader) : Number.NaN
|
|
699
835
|
if (Number.isFinite(declaredBytes) && declaredBytes > uploadLimitBytes) {
|
|
700
|
-
|
|
836
|
+
// Same policy as the actual-bytes branch (B-13): without
|
|
837
|
+
// Connection: close + socket destroy the sender keeps streaming a
|
|
838
|
+
// body this route will never read, so the endpoint's connection
|
|
839
|
+
// behavior would depend on whether Content-Length was declared.
|
|
840
|
+
rejectUploadTooLarge(req, res)
|
|
701
841
|
return
|
|
702
842
|
}
|
|
703
843
|
|
|
704
|
-
const
|
|
844
|
+
const suffix = randomBytes(6).toString('hex')
|
|
845
|
+
const tmp = join(staging, `upload-${suffix}`)
|
|
846
|
+
// Upload into a sibling partial first. Only rename publishes the target,
|
|
847
|
+
// giving the route a real commit point and a safe artifact to clean up
|
|
848
|
+
// when the browser disconnects during the long SFTP write.
|
|
849
|
+
const remotePartial = `${remotePath}.dsh-upload-${suffix}.partial`
|
|
850
|
+
const request = requestAbort(req, res)
|
|
705
851
|
|
|
706
852
|
// Stage and validate the WHOLE body before starting the NDJSON 200 —
|
|
707
853
|
// over-limit bodies get a real HTTP 413, not a failure frame inside
|
|
@@ -709,6 +855,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
709
855
|
const staged = await stageUploadBody(req, tmp, uploadLimitBytes)
|
|
710
856
|
|
|
711
857
|
if (!staged.ok) {
|
|
858
|
+
request.dispose()
|
|
712
859
|
if (staged.reason === 'too-large') {
|
|
713
860
|
if (!res.headersSent) {
|
|
714
861
|
rejectUploadTooLarge(req, res)
|
|
@@ -717,9 +864,9 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
717
864
|
}
|
|
718
865
|
return
|
|
719
866
|
}
|
|
720
|
-
if (!res.headersSent) {
|
|
721
|
-
writeJson(res, staged.reason === 'aborted' ? 400 : 500, { error: staged.error.message })
|
|
722
|
-
} else {
|
|
867
|
+
if (!res.headersSent && !res.destroyed) {
|
|
868
|
+
writeJson(res, staged.reason === 'aborted' ? 400 : 500, { error: redact(staged.error.message) })
|
|
869
|
+
} else if (!res.destroyed) {
|
|
723
870
|
res.destroy(staged.error)
|
|
724
871
|
}
|
|
725
872
|
return
|
|
@@ -732,6 +879,9 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
732
879
|
})
|
|
733
880
|
|
|
734
881
|
let responseClosed = false
|
|
882
|
+
let remoteUploadStarted = false
|
|
883
|
+
let commitStarted = false
|
|
884
|
+
let committed = false
|
|
735
885
|
const emit = (line: unknown): void => {
|
|
736
886
|
if (responseClosed || res.destroyed || res.writableEnded) return
|
|
737
887
|
try { res.write(`${JSON.stringify(line)}\n`) } catch { responseClosed = true }
|
|
@@ -740,14 +890,44 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
740
890
|
res.once('close', onResponseClose)
|
|
741
891
|
|
|
742
892
|
try {
|
|
893
|
+
request.signal.throwIfAborted()
|
|
743
894
|
emit({ type: 'progress', progress: { phase: 'connecting', file: remotePath, transferred: 0, total: staged.receivedBytes, percent: 0 } })
|
|
744
|
-
|
|
895
|
+
remoteUploadStarted = true
|
|
896
|
+
const outcome = await engine.upload(alias, tmp, remotePartial, false, progress => {
|
|
897
|
+
emit({ type: 'progress', progress: { ...progress, file: remotePath } })
|
|
898
|
+
}, request.signal)
|
|
899
|
+
request.signal.throwIfAborted()
|
|
900
|
+
// Tell the client that any transport loss after this frame has true
|
|
901
|
+
// result-unknown semantics: rename may publish even if its reply is
|
|
902
|
+
// lost. Before this point the final destination is untouched.
|
|
903
|
+
commitStarted = true
|
|
904
|
+
emit({ type: 'commit' })
|
|
905
|
+
await engine.rename(alias, remotePartial, remotePath, request.signal)
|
|
906
|
+
committed = true
|
|
745
907
|
emit({ type: 'result', ok: true, transferredBytes: outcome.bytes })
|
|
746
908
|
} catch (error) {
|
|
747
|
-
|
|
909
|
+
const body = request.signal.aborted
|
|
910
|
+
? commitStarted
|
|
911
|
+
? { error: 'upload connection closed after commit began; the remote result is unknown', code: 'RESULT_UNKNOWN' }
|
|
912
|
+
: { error: 'upload aborted before commit; the remote destination was not published', code: 'ABORTED' }
|
|
913
|
+
: safeErrorBody(error)
|
|
914
|
+
emit({
|
|
915
|
+
type: 'result',
|
|
916
|
+
ok: false,
|
|
917
|
+
error: body.error,
|
|
918
|
+
...(body.code !== undefined ? { code: body.code } : {}),
|
|
919
|
+
...('secret' in body && body.secret !== undefined ? { secret: body.secret } : {}),
|
|
920
|
+
...('hostKeyFingerprint' in body && body.hostKeyFingerprint !== undefined ? { hostKeyFingerprint: body.hostKeyFingerprint } : {}),
|
|
921
|
+
})
|
|
748
922
|
} finally {
|
|
749
|
-
// engine.upload
|
|
923
|
+
// Abort may make engine.upload/rename reject before ssh2's late
|
|
924
|
+
// callback. Retiring the connection plus best-effort removal ensures
|
|
925
|
+
// neither local staging nor a remote partial remains owned by route.
|
|
926
|
+
if (remoteUploadStarted && !committed) {
|
|
927
|
+
await engine.rm(alias, remotePartial, false).catch(() => undefined)
|
|
928
|
+
}
|
|
750
929
|
await removeStagingFile(tmp).catch(() => undefined)
|
|
930
|
+
request.dispose()
|
|
751
931
|
res.off('close', onResponseClose)
|
|
752
932
|
if (!responseClosed && !res.destroyed && !res.writableEnded) {
|
|
753
933
|
try { res.end() } catch { /* client gone */ }
|
|
@@ -769,31 +949,34 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
769
949
|
return
|
|
770
950
|
}
|
|
771
951
|
const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
|
|
952
|
+
const request = requestAbort(req, res)
|
|
772
953
|
try {
|
|
773
|
-
const outcome = await engine.download(alias, remotePath, tmp)
|
|
954
|
+
const outcome = await engine.download(alias, remotePath, tmp, undefined, request.signal)
|
|
955
|
+
request.signal.throwIfAborted()
|
|
774
956
|
res.writeHead(200, {
|
|
775
957
|
'content-type': 'application/octet-stream',
|
|
776
958
|
'content-length': String(outcome.bytes),
|
|
777
959
|
'content-disposition': `attachment; filename="${basename(remotePath).replace(/"/g, '')}"`,
|
|
778
960
|
'referrer-policy': 'no-referrer',
|
|
779
961
|
})
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
res.on('error', reject)
|
|
784
|
-
source.pipe(res)
|
|
785
|
-
source.on('end', resolve)
|
|
786
|
-
})
|
|
962
|
+
// pipeline destroys the local source when the response errors/closes;
|
|
963
|
+
// passing the same signal also interrupts an otherwise idle source.
|
|
964
|
+
await pipeline(createReadStream(tmp), res, { signal: request.signal })
|
|
787
965
|
} catch (error) {
|
|
788
|
-
if (!res.headersSent) {
|
|
789
|
-
|
|
790
|
-
|
|
966
|
+
if (!res.headersSent && !res.destroyed) {
|
|
967
|
+
// Same structured body (code + secret/fingerprint) as the JSON
|
|
968
|
+
// routes, so an interactive gate is still actionable here (B-14).
|
|
969
|
+
writeJson(res, request.signal.aborted ? 499 : 502, request.signal.aborted
|
|
970
|
+
? { error: 'download aborted by the client', code: 'ABORTED' }
|
|
971
|
+
: safeErrorBody(error))
|
|
972
|
+
} else if (!res.destroyed) {
|
|
791
973
|
// Mid-stream failure after headers: destroy so the browser does
|
|
792
974
|
// not hang waiting for the promised content-length bytes.
|
|
793
|
-
res.destroy()
|
|
975
|
+
res.destroy(toError(error))
|
|
794
976
|
}
|
|
795
977
|
} finally {
|
|
796
|
-
|
|
978
|
+
request.dispose()
|
|
979
|
+
await removeStagingFile(tmp).catch(() => undefined)
|
|
797
980
|
}
|
|
798
981
|
},
|
|
799
982
|
},
|
|
@@ -803,6 +986,12 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
803
986
|
const upgrade: WebUpgradeRoute = {
|
|
804
987
|
path: SSH_API.terminal,
|
|
805
988
|
handler: (req, socket, head) => {
|
|
989
|
+
if (disposed) {
|
|
990
|
+
// Teardown ran: this instance must not own a new terminal session.
|
|
991
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n')
|
|
992
|
+
socket.destroy()
|
|
993
|
+
return
|
|
994
|
+
}
|
|
806
995
|
if (!isLoopbackRequest(req)) {
|
|
807
996
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
808
997
|
socket.destroy()
|
|
@@ -818,75 +1007,118 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
818
1007
|
const cols = Number.parseInt(queryParam(url, 'cols') ?? '80', 10)
|
|
819
1008
|
const rows = Number.parseInt(queryParam(url, 'rows') ?? '24', 10)
|
|
820
1009
|
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
1010
|
+
// Promise.resolve also captures a non-conforming engine double that
|
|
1011
|
+
// throws synchronously from openShell().
|
|
1012
|
+
const pending = Promise.resolve().then(() => engine.openShell(alias, {
|
|
1013
|
+
cols: Number.isFinite(cols) ? cols : 80,
|
|
1014
|
+
rows: Number.isFinite(rows) ? rows : 24,
|
|
1015
|
+
}))
|
|
1016
|
+
const entry: TerminalEntry = { ws, session: undefined, pending }
|
|
1017
|
+
terminals.add(entry)
|
|
821
1018
|
let session: ShellSession | undefined
|
|
822
1019
|
let closed = false
|
|
823
1020
|
let paused = false
|
|
1021
|
+
const closeSession = (): void => {
|
|
1022
|
+
const opened = session
|
|
1023
|
+
session = undefined
|
|
1024
|
+
entry.session = undefined
|
|
1025
|
+
if (opened !== undefined) {
|
|
1026
|
+
try { opened.close() } catch { /* already closed */ }
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const closeSocket = (code: number, reason: string): void => {
|
|
1030
|
+
if (closed) return
|
|
1031
|
+
closed = true
|
|
1032
|
+
closeSession()
|
|
1033
|
+
try {
|
|
1034
|
+
if (ws.readyState === WebSocket.OPEN) ws.close(code, reason)
|
|
1035
|
+
else if (ws.readyState !== WebSocket.CLOSED) ws.terminate()
|
|
1036
|
+
} catch {
|
|
1037
|
+
try { ws.terminate() } catch { /* socket already gone */ }
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
824
1040
|
// Resume the shell once the socket's send buffer drains below the
|
|
825
1041
|
// low-water mark (transport backpressure).
|
|
826
1042
|
const resume = (): void => {
|
|
827
|
-
|
|
828
|
-
paused
|
|
829
|
-
|
|
1043
|
+
try {
|
|
1044
|
+
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
|
|
1045
|
+
paused = false
|
|
1046
|
+
session?.resume()
|
|
1047
|
+
}
|
|
1048
|
+
} catch {
|
|
1049
|
+
closeSocket(1011, 'terminal transport failure')
|
|
830
1050
|
}
|
|
831
1051
|
}
|
|
832
1052
|
const sendFrame = (frame: TerminalServerFrame): void => {
|
|
833
1053
|
if (closed || ws.readyState !== WebSocket.OPEN) return
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
paused
|
|
837
|
-
|
|
1054
|
+
try {
|
|
1055
|
+
ws.send(JSON.stringify(frame), resume)
|
|
1056
|
+
if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
|
|
1057
|
+
paused = true
|
|
1058
|
+
session?.pause()
|
|
1059
|
+
}
|
|
1060
|
+
} catch {
|
|
1061
|
+
closeSocket(1011, 'terminal transport failure')
|
|
838
1062
|
}
|
|
839
1063
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
session = undefined
|
|
843
|
-
if (opened !== undefined) opened.close()
|
|
844
|
-
}
|
|
845
|
-
engine.openShell(alias, {
|
|
846
|
-
cols: Number.isFinite(cols) ? cols : 80,
|
|
847
|
-
rows: Number.isFinite(rows) ? rows : 24,
|
|
848
|
-
}).then((opened) => {
|
|
849
|
-
if (ws.readyState !== WebSocket.OPEN) {
|
|
1064
|
+
pending.then((opened) => {
|
|
1065
|
+
if (closed || ws.readyState !== WebSocket.OPEN) {
|
|
850
1066
|
opened.close()
|
|
851
1067
|
return
|
|
852
1068
|
}
|
|
853
1069
|
session = opened
|
|
1070
|
+
entry.session = opened
|
|
854
1071
|
sendFrame({ type: 'ready', alias })
|
|
855
|
-
|
|
1072
|
+
// PTY output and exit details cross the credential boundary here, so
|
|
1073
|
+
// this route is the single redaction choke point for the terminal
|
|
1074
|
+
// (the service below streams raw bytes for any consumer). Redaction
|
|
1075
|
+
// is exact-match over known secrets: it is best-effort and may change
|
|
1076
|
+
// the displayed byte length, which is an accepted trade-off.
|
|
1077
|
+
opened.onData = (data) => sendFrame({ type: 'output', data: redact(data.toString('utf8')) })
|
|
856
1078
|
opened.onExit = (code, error) => {
|
|
857
|
-
sendFrame({ type: 'exit', code, error })
|
|
858
|
-
|
|
859
|
-
try { ws.close(1000) } catch { /* already closed */ }
|
|
1079
|
+
sendFrame({ type: 'exit', code, error: error === undefined ? undefined : redact(error) })
|
|
1080
|
+
closeSocket(1000, 'terminal exited')
|
|
860
1081
|
}
|
|
861
1082
|
}).catch((error) => {
|
|
862
|
-
sendFrame({ type: 'exit', code: null, error:
|
|
863
|
-
|
|
864
|
-
try { ws.close(1000) } catch { /* already closed */ }
|
|
1083
|
+
sendFrame({ type: 'exit', code: null, error: safeMessage(error) })
|
|
1084
|
+
closeSocket(1011, 'terminal setup failed')
|
|
865
1085
|
})
|
|
866
|
-
ws.on('message', (data) => {
|
|
867
|
-
|
|
1086
|
+
ws.on('message', (data, isBinary) => {
|
|
1087
|
+
// The entire EventEmitter callback is a no-throw boundary. Invalid
|
|
1088
|
+
// protocol affects this one socket only; it must never escape to the
|
|
1089
|
+
// host event loop and take down unrelated sessions.
|
|
868
1090
|
try {
|
|
869
|
-
|
|
1091
|
+
if (isBinary) {
|
|
1092
|
+
closeSocket(1003, 'binary terminal frames are unsupported')
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
const frame = terminalClientFrame(JSON.parse(String(data)) as unknown)
|
|
1096
|
+
if (frame === undefined) {
|
|
1097
|
+
closeSocket(1008, 'invalid terminal protocol frame')
|
|
1098
|
+
return
|
|
1099
|
+
}
|
|
1100
|
+
if (session === undefined) {
|
|
1101
|
+
closeSocket(1008, 'terminal is not ready')
|
|
1102
|
+
return
|
|
1103
|
+
}
|
|
1104
|
+
if (frame.type === 'input') session.send(frame.data)
|
|
1105
|
+
else session.resize(frame.cols, frame.rows)
|
|
870
1106
|
} catch {
|
|
871
|
-
|
|
872
|
-
}
|
|
873
|
-
if (frame.type === 'input') {
|
|
874
|
-
session?.send(frame.data)
|
|
875
|
-
} else if (frame.type === 'resize') {
|
|
876
|
-
session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
|
|
1107
|
+
closeSocket(1008, 'invalid terminal protocol frame')
|
|
877
1108
|
}
|
|
878
1109
|
})
|
|
879
1110
|
ws.on('close', () => {
|
|
880
1111
|
closed = true
|
|
881
1112
|
closeSession()
|
|
1113
|
+
terminals.delete(entry)
|
|
882
1114
|
})
|
|
883
1115
|
ws.on('error', () => {
|
|
884
|
-
|
|
885
|
-
|
|
1116
|
+
closeSocket(1011, 'terminal socket error')
|
|
1117
|
+
terminals.delete(entry)
|
|
886
1118
|
})
|
|
887
1119
|
})
|
|
888
1120
|
},
|
|
889
1121
|
}
|
|
890
1122
|
|
|
891
|
-
return { routes, upgrade }
|
|
1123
|
+
return { routes, upgrade, dispose }
|
|
892
1124
|
}
|