@tiphareth/dsh-hardssh 0.1.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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
|
@@ -0,0 +1,892 @@
|
|
|
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 ApiErrorBody, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
|
|
20
|
+
import type { HostStore } from './store.ts'
|
|
21
|
+
import { HostKeyMismatchError, HostKeyUnknownError, type KnownHostsStore } from './known-hosts.ts'
|
|
22
|
+
import { NeedsPasswordError } from './engine.ts'
|
|
23
|
+
|
|
24
|
+
/** Cap on JSON request bodies (host entries and exec payloads are small). */
|
|
25
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024
|
|
26
|
+
|
|
27
|
+
/** Cap on declared upload bodies (staged to disk before SFTP). */
|
|
28
|
+
export const DEFAULT_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
|
|
29
|
+
|
|
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
|
+
/** Pause the shell when the socket's send buffer exceeds this… */
|
|
38
|
+
const BACKPRESSURE_HIGH_WATER = 1024 * 1024
|
|
39
|
+
|
|
40
|
+
/** …and resume once it drains below this. */
|
|
41
|
+
const BACKPRESSURE_LOW_WATER = 512 * 1024
|
|
42
|
+
|
|
43
|
+
/** Result of staging one upload body under a hard byte limit. */
|
|
44
|
+
type StageUploadResult =
|
|
45
|
+
| { ok: true; receivedBytes: number }
|
|
46
|
+
| { ok: false; reason: 'too-large' | 'aborted' | 'request-error' | 'write-error'; receivedBytes: number; error: Error }
|
|
47
|
+
|
|
48
|
+
function toError(error: unknown): Error {
|
|
49
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Idempotent staging-file cleanup (ENOENT is fine; other errors surface). */
|
|
53
|
+
async function removeStagingFile(path: string): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
await unlink(path)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return
|
|
58
|
+
throw error
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Stage a request body to `tmpPath` while enforcing a hard cap on the bytes
|
|
64
|
+
* ACTUALLY received (`Content-Length` is only a fast-reject hint — chunked
|
|
65
|
+
* and under-declared bodies must be caught here). On every failure the sink
|
|
66
|
+
* is destroyed, its 'close' waited for (Windows needs the handle released
|
|
67
|
+
* before unlink), and the partial file removed. Resolves only after the
|
|
68
|
+
* write stream finished, so a success means the body is fully on disk.
|
|
69
|
+
*/
|
|
70
|
+
function stageUploadBody(req: IncomingMessage, tmpPath: string, limitBytes: number): Promise<StageUploadResult> {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const sink = createWriteStream(tmpPath, { flags: 'wx' })
|
|
73
|
+
|
|
74
|
+
let receivedBytes = 0
|
|
75
|
+
let terminal = false
|
|
76
|
+
let result: StageUploadResult | undefined
|
|
77
|
+
|
|
78
|
+
const detachRequestListeners = (): void => {
|
|
79
|
+
req.off('data', onData)
|
|
80
|
+
req.off('end', onEnd)
|
|
81
|
+
req.off('aborted', onAborted)
|
|
82
|
+
req.off('error', onRequestError)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const resolveFailureAfterClose = (failure: Exclude<StageUploadResult, { ok: true }>): void => {
|
|
86
|
+
if (terminal) return
|
|
87
|
+
terminal = true
|
|
88
|
+
result = failure
|
|
89
|
+
detachRequestListeners()
|
|
90
|
+
// Stop consuming body bytes; the socket is torn down after the 413.
|
|
91
|
+
req.pause()
|
|
92
|
+
if (!sink.destroyed) sink.destroy()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const onData = (chunk: Buffer | string): void => {
|
|
96
|
+
if (terminal) return
|
|
97
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
98
|
+
receivedBytes += buffer.length
|
|
99
|
+
if (receivedBytes > limitBytes) {
|
|
100
|
+
// The over-limit chunk is NOT written, so the temp file never
|
|
101
|
+
// exceeds the cap.
|
|
102
|
+
resolveFailureAfterClose({ ok: false, reason: 'too-large', receivedBytes, error: new Error('upload body too large') })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
// Respect filesystem backpressure instead of buffering unbounded data.
|
|
106
|
+
if (!sink.write(buffer)) {
|
|
107
|
+
req.pause()
|
|
108
|
+
sink.once('drain', () => { if (!terminal) req.resume() })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const onEnd = (): void => {
|
|
113
|
+
if (terminal) return
|
|
114
|
+
detachRequestListeners()
|
|
115
|
+
sink.end()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const onAborted = (): void => {
|
|
119
|
+
resolveFailureAfterClose({ ok: false, reason: 'aborted', receivedBytes, error: new Error('upload aborted by the client') })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const onRequestError = (error: Error): void => {
|
|
123
|
+
resolveFailureAfterClose({ ok: false, reason: 'request-error', receivedBytes, error: toError(error) })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sink.on('error', (error) => {
|
|
127
|
+
resolveFailureAfterClose({ ok: false, reason: 'write-error', receivedBytes, error: toError(error) })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
sink.on('finish', () => {
|
|
131
|
+
if (terminal) return
|
|
132
|
+
terminal = true
|
|
133
|
+
detachRequestListeners()
|
|
134
|
+
resolve({ ok: true, receivedBytes })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
sink.on('close', () => {
|
|
138
|
+
if (result === undefined) return
|
|
139
|
+
const failure = result
|
|
140
|
+
result = undefined
|
|
141
|
+
void removeStagingFile(tmpPath).catch(() => undefined).then(() => resolve(failure))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
req.on('data', onData)
|
|
145
|
+
req.once('end', onEnd)
|
|
146
|
+
req.once('aborted', onAborted)
|
|
147
|
+
req.once('error', onRequestError)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 413 + connection: close so the sender cannot keep streaming into the socket. */
|
|
152
|
+
function rejectUploadTooLarge(req: IncomingMessage, res: ServerResponse): void {
|
|
153
|
+
const payload = JSON.stringify({ error: 'upload body too large' })
|
|
154
|
+
res.writeHead(413, {
|
|
155
|
+
'content-type': 'application/json; charset=utf-8',
|
|
156
|
+
'content-length': String(Buffer.byteLength(payload)),
|
|
157
|
+
'connection': 'close',
|
|
158
|
+
'referrer-policy': 'no-referrer',
|
|
159
|
+
})
|
|
160
|
+
res.end(payload, () => {
|
|
161
|
+
if (!req.socket.destroyed) req.socket.destroy()
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
|
|
166
|
+
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
167
|
+
const address = request.socket.remoteAddress
|
|
168
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
169
|
+
const host = request.headers.host
|
|
170
|
+
if (typeof host !== 'string') return false
|
|
171
|
+
let hostUrl: URL
|
|
172
|
+
try {
|
|
173
|
+
hostUrl = new URL(`http://${host}`)
|
|
174
|
+
} catch {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
178
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
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 {
|
|
198
|
+
if (error instanceof HostKeyUnknownError) {
|
|
199
|
+
return { error: error.message, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 }
|
|
200
|
+
}
|
|
201
|
+
if (error instanceof HostKeyMismatchError) {
|
|
202
|
+
return { error: error.message, code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual, hostKeyMismatch: { expected: error.expected, actual: error.actual } }
|
|
203
|
+
}
|
|
204
|
+
if (error instanceof NeedsPasswordError) {
|
|
205
|
+
return { error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret }
|
|
206
|
+
}
|
|
207
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Read a JSON request body (undefined when too large or unparseable). */
|
|
211
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
|
|
212
|
+
const chunks: Buffer[] = []
|
|
213
|
+
let size = 0
|
|
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
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Route family dependencies. */
|
|
235
|
+
export interface SshRoutesDeps {
|
|
236
|
+
/** The host store (CRUD; vault-backed for secrets). */
|
|
237
|
+
store: import('./store.ts').SecureHostStore
|
|
238
|
+
/** The engine (ops). */
|
|
239
|
+
engine: SshEngine
|
|
240
|
+
/** Host-key trust store (TOFU); absent → host-key verification disabled. */
|
|
241
|
+
knownHosts?: KnownHostsStore
|
|
242
|
+
/** Credential vault (optional; enables /api/dsh-ssh/vault endpoints). */
|
|
243
|
+
vault?: import('./vault.ts').Vault
|
|
244
|
+
/** SSH-bound workspace ledger (host-delete reference check). */
|
|
245
|
+
ledger?: import('../ledger.ts').SshWorkspaceLedger
|
|
246
|
+
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
247
|
+
stagingDir?: string
|
|
248
|
+
/** Hard cap on ACTUAL upload bytes accepted (chunked requests included).
|
|
249
|
+
* Defaults to 4 GiB; tests inject a small value. */
|
|
250
|
+
uploadLimitBytes?: number
|
|
251
|
+
/** Called after a host PATCH/DELETE persisted, so derived caches (e.g. the
|
|
252
|
+
* remote environment cache) can drop that alias. */
|
|
253
|
+
onHostInvalidated?: (alias: string) => void
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
|
|
258
|
+
* @param deps - store, engine, staging dir.
|
|
259
|
+
* @returns routes and the upgrade route.
|
|
260
|
+
*/
|
|
261
|
+
export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
|
|
262
|
+
const { store, engine } = deps
|
|
263
|
+
const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
|
|
264
|
+
const uploadLimitBytes = deps.uploadLimitBytes ?? DEFAULT_MAX_UPLOAD_BYTES
|
|
265
|
+
if (!Number.isSafeInteger(uploadLimitBytes) || uploadLimitBytes < 0) {
|
|
266
|
+
throw new RangeError('uploadLimitBytes must be a non-negative safe integer')
|
|
267
|
+
}
|
|
268
|
+
// The upload route stages request bodies here; it must exist before the
|
|
269
|
+
// first request (a missing dir would hang the first upload forever).
|
|
270
|
+
mkdirSync(staging, { recursive: true })
|
|
271
|
+
|
|
272
|
+
/** Guard helper: fence + method check. */
|
|
273
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
274
|
+
if (!isLoopbackRequest(req)) {
|
|
275
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
276
|
+
return false
|
|
277
|
+
}
|
|
278
|
+
if (req.method !== method) {
|
|
279
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
280
|
+
return false
|
|
281
|
+
}
|
|
282
|
+
return true
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const routes: WebRoute[] = [
|
|
286
|
+
// ------------------------------------------------------------ hosts
|
|
287
|
+
{
|
|
288
|
+
kind: 'exact',
|
|
289
|
+
path: SSH_API.hosts,
|
|
290
|
+
handler: async (req, res) => {
|
|
291
|
+
// One handler per path (the webserver keyed route registry rejects
|
|
292
|
+
// duplicate (kind, path)); dispatch by HTTP method here.
|
|
293
|
+
const method = req.method ?? 'GET'
|
|
294
|
+
if (!isLoopbackRequest(req)) {
|
|
295
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
299
|
+
if (method === 'GET') {
|
|
300
|
+
writeJson(res, 200, { hosts: engine.list(queryParam(url, 'query')) })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (method === 'POST') {
|
|
304
|
+
const body = await readJsonBody(req)
|
|
305
|
+
if (body === undefined) {
|
|
306
|
+
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const entry = await store.create(body as unknown as HostPayload)
|
|
311
|
+
writeJson(res, 201, { host: store.summarize(entry) })
|
|
312
|
+
} catch (error) {
|
|
313
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
314
|
+
}
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
if (method !== 'PATCH' && method !== 'DELETE') {
|
|
318
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
const alias = queryParam(url, 'alias')
|
|
322
|
+
if (alias === undefined || alias === '') {
|
|
323
|
+
writeJson(res, 400, { error: 'alias query parameter is required' })
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
if (method === 'PATCH') {
|
|
327
|
+
const body = await readJsonBody(req)
|
|
328
|
+
if (body === undefined) {
|
|
329
|
+
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const entry = await store.update(alias, body as unknown as Partial<HostPayload>)
|
|
334
|
+
// Persist first. A validation/save failure must leave all
|
|
335
|
+
// existing connections untouched. drain lets in-flight
|
|
336
|
+
// operations finish while generation invalidation guarantees
|
|
337
|
+
// subsequent acquisitions cannot reuse the old config's
|
|
338
|
+
// connection (or publish one built from it).
|
|
339
|
+
engine.invalidate(alias, { includeDependents: true, mode: 'drain' })
|
|
340
|
+
deps.onHostInvalidated?.(alias)
|
|
341
|
+
writeJson(res, 200, { host: store.summarize(entry) })
|
|
342
|
+
} catch (error) {
|
|
343
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
344
|
+
}
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
if (method === 'DELETE') {
|
|
348
|
+
// Reference guard: a host still backing SSH-bound workspaces must
|
|
349
|
+
// not be deleted (the workspaces would silently lose their
|
|
350
|
+
// transport). Return 409 with the referencing workspace titles.
|
|
351
|
+
if (deps.ledger !== undefined) {
|
|
352
|
+
const referenced = (await deps.ledger.list()).filter(record => record.alias === alias)
|
|
353
|
+
if (referenced.length > 0) {
|
|
354
|
+
writeJson(res, 409, {
|
|
355
|
+
error: `alias '${alias}' is still referenced by ${referenced.length} SSH workspace(s) (${referenced.map(r => r.title).join('、')}) — delete those workspaces first`,
|
|
356
|
+
code: 'HOST_IN_USE',
|
|
357
|
+
workspaces: referenced.map(record => ({ id: record.id, title: record.title })),
|
|
358
|
+
})
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
// Delete first so a missing alias or failed store write does not
|
|
364
|
+
// disturb a live tunnel/connection.
|
|
365
|
+
await store.delete(alias)
|
|
366
|
+
// Tunnels own leases: release them before forcing the remaining
|
|
367
|
+
// pooled transport so records/sockets clean up normally.
|
|
368
|
+
engine.stopAllTunnels(alias)
|
|
369
|
+
engine.invalidate(alias, { mode: 'force' })
|
|
370
|
+
deps.onHostInvalidated?.(alias)
|
|
371
|
+
writeJson(res, 200, { ok: true })
|
|
372
|
+
} catch (error) {
|
|
373
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
374
|
+
}
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` })
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
kind: 'exact',
|
|
382
|
+
path: SSH_API.importSshConfig,
|
|
383
|
+
handler: async (req, res) => {
|
|
384
|
+
if (!guard(req, res, 'POST')) return
|
|
385
|
+
try {
|
|
386
|
+
writeJson(res, 200, { result: store.importFromSshConfig() })
|
|
387
|
+
} catch (error) {
|
|
388
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
// -------------------------------------------------------- known-hosts
|
|
393
|
+
{
|
|
394
|
+
kind: 'exact',
|
|
395
|
+
path: SSH_API.knownHosts,
|
|
396
|
+
handler: async (req, res) => {
|
|
397
|
+
if (!isLoopbackRequest(req)) {
|
|
398
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
const method = req.method ?? 'GET'
|
|
402
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
403
|
+
const knownHosts = deps.knownHosts
|
|
404
|
+
if (knownHosts === undefined) {
|
|
405
|
+
writeJson(res, 404, { error: 'host-key trust store is not enabled' })
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
if (method === 'GET') {
|
|
409
|
+
const alias = queryParam(url, 'alias')
|
|
410
|
+
const records = knownHosts.list()
|
|
411
|
+
.filter(record => alias === undefined || alias === '' || record.alias === alias)
|
|
412
|
+
.map(record => ({
|
|
413
|
+
alias: record.alias,
|
|
414
|
+
host: record.host,
|
|
415
|
+
port: record.port,
|
|
416
|
+
keyType: record.keyType,
|
|
417
|
+
fingerprint: record.fingerprint,
|
|
418
|
+
status: record.status,
|
|
419
|
+
firstSeenAt: record.firstSeenAt,
|
|
420
|
+
confirmedAt: record.confirmedAt,
|
|
421
|
+
} satisfies import('./protocol.ts').KnownHostView))
|
|
422
|
+
writeJson(res, 200, { records })
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
if (method !== 'POST') {
|
|
426
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` })
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
const body = await readJsonBody(req)
|
|
430
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
431
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
432
|
+
if (alias === '' || (action !== 'trust' && action !== 'forget')) {
|
|
433
|
+
writeJson(res, 400, { error: 'alias and action (trust|forget) are required' })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
if (action === 'trust') knownHosts.trust(alias)
|
|
438
|
+
else knownHosts.forget(alias)
|
|
439
|
+
writeJson(res, 200, { ok: true })
|
|
440
|
+
} catch (error) {
|
|
441
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
// ------------------------------------------------------------- vault
|
|
446
|
+
{
|
|
447
|
+
kind: 'exact',
|
|
448
|
+
path: SSH_API.vault,
|
|
449
|
+
handler: async (req, res) => {
|
|
450
|
+
if (!isLoopbackRequest(req)) {
|
|
451
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
const vault = deps.vault
|
|
455
|
+
if (vault === undefined) {
|
|
456
|
+
writeJson(res, 404, { error: 'credential vault is not enabled' })
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
const method = req.method ?? 'GET'
|
|
460
|
+
if (method === 'GET') {
|
|
461
|
+
writeJson(res, 200, { status: vault.status() })
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
if (method !== 'POST') {
|
|
465
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` })
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
const body = await readJsonBody(req)
|
|
469
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
470
|
+
try {
|
|
471
|
+
if (action === 'status') {
|
|
472
|
+
writeJson(res, 200, { status: vault.status() })
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
if (action === 'unlock') {
|
|
476
|
+
const password = typeof body?.password === 'string' ? body.password : ''
|
|
477
|
+
await vault.unlock(password)
|
|
478
|
+
writeJson(res, 200, { ok: true, status: vault.status() })
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
if (action === 'rekey') {
|
|
482
|
+
const password = typeof body?.password === 'string' ? body.password : ''
|
|
483
|
+
await vault.rekey(password)
|
|
484
|
+
writeJson(res, 200, { ok: true, status: vault.status() })
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
writeJson(res, 400, { error: 'action must be status|unlock|rekey' })
|
|
488
|
+
} catch (error) {
|
|
489
|
+
// Vault errors carry stable codes (VAULT_LOCKED / VAULT_AUTH /
|
|
490
|
+
// VAULT_LOCKOUT) + retry info; surface the message + code.
|
|
491
|
+
const record = error as { code?: unknown }
|
|
492
|
+
writeJson(res, 401, {
|
|
493
|
+
error: error instanceof Error ? error.message : String(error),
|
|
494
|
+
code: typeof record.code === 'string' ? record.code : undefined,
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
// --------------------------------------------------- session-secret
|
|
500
|
+
{
|
|
501
|
+
kind: 'exact',
|
|
502
|
+
path: SSH_API.sessionSecret,
|
|
503
|
+
handler: async (req, res) => {
|
|
504
|
+
if (!isLoopbackRequest(req)) {
|
|
505
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
if ((req.method ?? '') !== 'POST') {
|
|
509
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
const body = await readJsonBody(req)
|
|
513
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
514
|
+
const password = typeof body?.password === 'string' ? body.password : undefined
|
|
515
|
+
const passphrase = typeof body?.passphrase === 'string' ? body.passphrase : undefined
|
|
516
|
+
if (alias === '' || (password === undefined && passphrase === undefined)) {
|
|
517
|
+
writeJson(res, 400, { error: 'alias and password|passphrase are required' })
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
// Memory-only: never persisted; consumed by connectChain for the
|
|
521
|
+
// pooled connection's lifetime (idle 30 min auto-disconnect).
|
|
522
|
+
engine.setSessionPassword(alias, { password, passphrase })
|
|
523
|
+
writeJson(res, 200, { ok: true })
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
// --------------------------------------------------- connections
|
|
527
|
+
{
|
|
528
|
+
kind: 'exact',
|
|
529
|
+
path: SSH_API.connections,
|
|
530
|
+
handler: async (req, res) => {
|
|
531
|
+
if (!isLoopbackRequest(req)) {
|
|
532
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
if ((req.method ?? '') !== 'GET') {
|
|
536
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
writeJson(res, 200, { connected: engine.connectedAliases() })
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
// ------------------------------------------------------------ ops
|
|
543
|
+
{
|
|
544
|
+
kind: 'exact',
|
|
545
|
+
path: SSH_API.test,
|
|
546
|
+
handler: async (req, res) => {
|
|
547
|
+
if (!guard(req, res, 'POST')) return
|
|
548
|
+
const body = await readJsonBody(req)
|
|
549
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
550
|
+
if (alias === '') {
|
|
551
|
+
writeJson(res, 400, { error: 'alias is required' })
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
writeJson(res, 200, { result: await engine.test(alias) })
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (error instanceof NeedsPasswordError) {
|
|
558
|
+
// Not a failure: the connection needs a credential the user
|
|
559
|
+
// hasn't entered this session yet. Return a structured prompt
|
|
560
|
+
// (200) so the GUI can show the password dialog and retry.
|
|
561
|
+
writeJson(res, 200, {
|
|
562
|
+
result: { ok: false, error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret },
|
|
563
|
+
})
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
writeJson(res, 500, errorBody(error))
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
kind: 'exact',
|
|
572
|
+
path: SSH_API.exec,
|
|
573
|
+
handler: async (req, res) => {
|
|
574
|
+
if (!guard(req, res, 'POST')) return
|
|
575
|
+
const body = await readJsonBody(req)
|
|
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
|
|
583
|
+
try {
|
|
584
|
+
writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) })
|
|
585
|
+
} catch (error) {
|
|
586
|
+
writeJson(res, 500, errorBody(error))
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
kind: 'exact',
|
|
592
|
+
path: SSH_API.cluster,
|
|
593
|
+
handler: async (req, res) => {
|
|
594
|
+
if (!guard(req, res, 'POST')) return
|
|
595
|
+
const body = await readJsonBody(req)
|
|
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
|
|
606
|
+
try {
|
|
607
|
+
writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers }) })
|
|
608
|
+
} catch (error) {
|
|
609
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
// ------------------------------------------------------------- ls
|
|
614
|
+
{
|
|
615
|
+
kind: 'exact',
|
|
616
|
+
path: SSH_API.ls,
|
|
617
|
+
handler: async (req, res) => {
|
|
618
|
+
if (!guard(req, res, 'GET')) return
|
|
619
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
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
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
writeJson(res, 200, { entries: await engine.ls(alias, path) })
|
|
628
|
+
} catch (error) {
|
|
629
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
// --------------------------------------------------------- tunnel
|
|
634
|
+
{
|
|
635
|
+
kind: 'exact',
|
|
636
|
+
path: SSH_API.tunnel,
|
|
637
|
+
handler: async (req, res) => {
|
|
638
|
+
if (!guard(req, res, 'POST')) return
|
|
639
|
+
const body = await readJsonBody(req)
|
|
640
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
641
|
+
if (action === 'list') {
|
|
642
|
+
writeJson(res, 200, { tunnels: engine.listTunnels() })
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
if (action === 'start') {
|
|
646
|
+
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
647
|
+
const remotePort = typeof body?.remotePort === 'number' ? body.remotePort : undefined
|
|
648
|
+
if (alias === '' || remotePort === undefined) {
|
|
649
|
+
writeJson(res, 400, { error: 'alias and remotePort are required' })
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
const tunnel = await engine.startTunnel(alias, {
|
|
654
|
+
remotePort,
|
|
655
|
+
remoteHost: typeof body?.remoteHost === 'string' && body.remoteHost !== '' ? body.remoteHost : undefined,
|
|
656
|
+
localPort: typeof body?.localPort === 'number' ? body.localPort : undefined,
|
|
657
|
+
})
|
|
658
|
+
writeJson(res, 200, { tunnel })
|
|
659
|
+
} catch (error) {
|
|
660
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
661
|
+
}
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
if (action === 'stop') {
|
|
665
|
+
const id = typeof body?.tunnelId === 'string' ? body.tunnelId : ''
|
|
666
|
+
if (id === '') {
|
|
667
|
+
writeJson(res, 400, { error: 'tunnelId is required' })
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
writeJson(res, 200, { ok: engine.stopTunnel(id) })
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
if (action === 'stop-all') {
|
|
674
|
+
const alias = typeof body?.alias === 'string' ? body.alias : undefined
|
|
675
|
+
writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === '' ? undefined : alias) })
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
writeJson(res, 400, { error: `unknown action '${action}'` })
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
// --------------------------------------------------------- upload
|
|
682
|
+
{
|
|
683
|
+
kind: 'exact',
|
|
684
|
+
path: SSH_API.upload,
|
|
685
|
+
handler: async (req, res) => {
|
|
686
|
+
if (!guard(req, res, 'POST')) return
|
|
687
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
688
|
+
const alias = queryParam(url, 'alias')
|
|
689
|
+
const remotePath = queryParam(url, 'remotePath')
|
|
690
|
+
if (alias === undefined || remotePath === undefined) {
|
|
691
|
+
writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Content-Length is only a fast-reject path; never trust it as proof
|
|
696
|
+
// the actual body is within the limit (chunked / under-declared).
|
|
697
|
+
const contentLengthHeader = req.headers['content-length']
|
|
698
|
+
const declaredBytes = typeof contentLengthHeader === 'string' ? Number(contentLengthHeader) : Number.NaN
|
|
699
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > uploadLimitBytes) {
|
|
700
|
+
writeJson(res, 413, { error: 'upload body too large' })
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const tmp = join(staging, `upload-${randomBytes(6).toString('hex')}`)
|
|
705
|
+
|
|
706
|
+
// Stage and validate the WHOLE body before starting the NDJSON 200 —
|
|
707
|
+
// over-limit bodies get a real HTTP 413, not a failure frame inside
|
|
708
|
+
// an already-started 200 stream.
|
|
709
|
+
const staged = await stageUploadBody(req, tmp, uploadLimitBytes)
|
|
710
|
+
|
|
711
|
+
if (!staged.ok) {
|
|
712
|
+
if (staged.reason === 'too-large') {
|
|
713
|
+
if (!res.headersSent) {
|
|
714
|
+
rejectUploadTooLarge(req, res)
|
|
715
|
+
} else {
|
|
716
|
+
res.destroy()
|
|
717
|
+
}
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
if (!res.headersSent) {
|
|
721
|
+
writeJson(res, staged.reason === 'aborted' ? 400 : 500, { error: staged.error.message })
|
|
722
|
+
} else {
|
|
723
|
+
res.destroy(staged.error)
|
|
724
|
+
}
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
res.writeHead(200, {
|
|
729
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
730
|
+
'cache-control': 'no-cache',
|
|
731
|
+
'referrer-policy': 'no-referrer',
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
let responseClosed = false
|
|
735
|
+
const emit = (line: unknown): void => {
|
|
736
|
+
if (responseClosed || res.destroyed || res.writableEnded) return
|
|
737
|
+
try { res.write(`${JSON.stringify(line)}\n`) } catch { responseClosed = true }
|
|
738
|
+
}
|
|
739
|
+
const onResponseClose = (): void => { responseClosed = true }
|
|
740
|
+
res.once('close', onResponseClose)
|
|
741
|
+
|
|
742
|
+
try {
|
|
743
|
+
emit({ type: 'progress', progress: { phase: 'connecting', file: remotePath, transferred: 0, total: staged.receivedBytes, percent: 0 } })
|
|
744
|
+
const outcome = await engine.upload(alias, tmp, remotePath, false, progress => emit({ type: 'progress', progress }))
|
|
745
|
+
emit({ type: 'result', ok: true, transferredBytes: outcome.bytes })
|
|
746
|
+
} catch (error) {
|
|
747
|
+
emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
748
|
+
} finally {
|
|
749
|
+
// engine.upload() has settled (read handle released) — safe to unlink.
|
|
750
|
+
await removeStagingFile(tmp).catch(() => undefined)
|
|
751
|
+
res.off('close', onResponseClose)
|
|
752
|
+
if (!responseClosed && !res.destroyed && !res.writableEnded) {
|
|
753
|
+
try { res.end() } catch { /* client gone */ }
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
// ------------------------------------------------------- download
|
|
759
|
+
{
|
|
760
|
+
kind: 'exact',
|
|
761
|
+
path: SSH_API.download,
|
|
762
|
+
handler: async (req, res) => {
|
|
763
|
+
if (!guard(req, res, 'GET')) return
|
|
764
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
765
|
+
const alias = queryParam(url, 'alias')
|
|
766
|
+
const remotePath = queryParam(url, 'remotePath')
|
|
767
|
+
if (alias === undefined || remotePath === undefined) {
|
|
768
|
+
writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
|
|
772
|
+
try {
|
|
773
|
+
const outcome = await engine.download(alias, remotePath, tmp)
|
|
774
|
+
res.writeHead(200, {
|
|
775
|
+
'content-type': 'application/octet-stream',
|
|
776
|
+
'content-length': String(outcome.bytes),
|
|
777
|
+
'content-disposition': `attachment; filename="${basename(remotePath).replace(/"/g, '')}"`,
|
|
778
|
+
'referrer-policy': 'no-referrer',
|
|
779
|
+
})
|
|
780
|
+
await new Promise<void>((resolve, reject) => {
|
|
781
|
+
const source = createReadStream(tmp)
|
|
782
|
+
source.on('error', reject)
|
|
783
|
+
res.on('error', reject)
|
|
784
|
+
source.pipe(res)
|
|
785
|
+
source.on('end', resolve)
|
|
786
|
+
})
|
|
787
|
+
} catch (error) {
|
|
788
|
+
if (!res.headersSent) {
|
|
789
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
790
|
+
} else {
|
|
791
|
+
// Mid-stream failure after headers: destroy so the browser does
|
|
792
|
+
// not hang waiting for the promised content-length bytes.
|
|
793
|
+
res.destroy()
|
|
794
|
+
}
|
|
795
|
+
} finally {
|
|
796
|
+
await unlink(tmp).catch(() => undefined)
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
]
|
|
801
|
+
|
|
802
|
+
// ---------------------------------------------- terminal (upgrade)
|
|
803
|
+
const upgrade: WebUpgradeRoute = {
|
|
804
|
+
path: SSH_API.terminal,
|
|
805
|
+
handler: (req, socket, head) => {
|
|
806
|
+
if (!isLoopbackRequest(req)) {
|
|
807
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
808
|
+
socket.destroy()
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
812
|
+
const alias = queryParam(url, 'alias')
|
|
813
|
+
if (alias === undefined) {
|
|
814
|
+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
|
815
|
+
socket.destroy()
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
const cols = Number.parseInt(queryParam(url, 'cols') ?? '80', 10)
|
|
819
|
+
const rows = Number.parseInt(queryParam(url, 'rows') ?? '24', 10)
|
|
820
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
821
|
+
let session: ShellSession | undefined
|
|
822
|
+
let closed = false
|
|
823
|
+
let paused = false
|
|
824
|
+
// Resume the shell once the socket's send buffer drains below the
|
|
825
|
+
// low-water mark (transport backpressure).
|
|
826
|
+
const resume = (): void => {
|
|
827
|
+
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
|
|
828
|
+
paused = false
|
|
829
|
+
session?.resume()
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const sendFrame = (frame: TerminalServerFrame): void => {
|
|
833
|
+
if (closed || ws.readyState !== WebSocket.OPEN) return
|
|
834
|
+
ws.send(JSON.stringify(frame), resume)
|
|
835
|
+
if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
|
|
836
|
+
paused = true
|
|
837
|
+
session?.pause()
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const closeSession = (): void => {
|
|
841
|
+
const opened = session
|
|
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) {
|
|
850
|
+
opened.close()
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
session = opened
|
|
854
|
+
sendFrame({ type: 'ready', alias })
|
|
855
|
+
opened.onData = (data) => sendFrame({ type: 'output', data: data.toString('utf8') })
|
|
856
|
+
opened.onExit = (code, error) => {
|
|
857
|
+
sendFrame({ type: 'exit', code, error })
|
|
858
|
+
closed = true
|
|
859
|
+
try { ws.close(1000) } catch { /* already closed */ }
|
|
860
|
+
}
|
|
861
|
+
}).catch((error) => {
|
|
862
|
+
sendFrame({ type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
|
|
863
|
+
closed = true
|
|
864
|
+
try { ws.close(1000) } catch { /* already closed */ }
|
|
865
|
+
})
|
|
866
|
+
ws.on('message', (data) => {
|
|
867
|
+
let frame: TerminalClientFrame
|
|
868
|
+
try {
|
|
869
|
+
frame = JSON.parse(String(data)) as TerminalClientFrame
|
|
870
|
+
} catch {
|
|
871
|
+
return
|
|
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))
|
|
877
|
+
}
|
|
878
|
+
})
|
|
879
|
+
ws.on('close', () => {
|
|
880
|
+
closed = true
|
|
881
|
+
closeSession()
|
|
882
|
+
})
|
|
883
|
+
ws.on('error', () => {
|
|
884
|
+
closed = true
|
|
885
|
+
closeSession()
|
|
886
|
+
})
|
|
887
|
+
})
|
|
888
|
+
},
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return { routes, upgrade }
|
|
892
|
+
}
|