dsh-ssh-workbench 0.0.0-stage → 1.6.4
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/CHANGELOG.md +504 -0
- package/CONTRIBUTING.md +62 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/SECURITY.md +25 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +124 -0
- package/docs/quickstart.md +94 -0
- package/lib/client.js +4977 -0
- package/lib/core/activity.js +83 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +799 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +71 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-bash-tool.js +321 -0
- package/lib/core/remote-bash.js +337 -0
- package/lib/core/secrets.js +96 -0
- package/lib/core/server-reference.js +299 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/targets.js +250 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +450 -0
- package/lib/routes.js +733 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +88 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- package/vendor/xterm.js +53 -0
package/lib/routes.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON data plane for the settings page and the right-sidebar panel.
|
|
3
|
+
*
|
|
4
|
+
* One handler serves both carriers:
|
|
5
|
+
* - Web: `ctx.webServer.register({kind:'prefix', path:'/dsh-ssh-workbench'})`.
|
|
6
|
+
* - Desktop: the Electron shell has no webServer, so the same operations are
|
|
7
|
+
* registered as exact Connection Fetch routes under `/api/dsh-ssh-workbench/*`
|
|
8
|
+
* (GET/POST only, matching the host's route registry).
|
|
9
|
+
*
|
|
10
|
+
* Every browser-facing request is fenced by `ctx.connection.requestRejection`
|
|
11
|
+
* when the Connection service exists.
|
|
12
|
+
*/
|
|
13
|
+
import { Readable } from 'node:stream'
|
|
14
|
+
|
|
15
|
+
import { createLocalFileApi, LOCAL_FILE_REF } from './core/local-file-api.js'
|
|
16
|
+
import { effectiveDialect } from './core/dialect.js'
|
|
17
|
+
import { SshError } from './core/errors.js'
|
|
18
|
+
|
|
19
|
+
const PREFIX = '/dsh-ssh-workbench'
|
|
20
|
+
const MAX_BODY = 8 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
/** Marker error for request-level failures that carry their own HTTP status. */
|
|
23
|
+
class HttpStatusError extends Error {
|
|
24
|
+
constructor(status, message) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = 'HttpStatusError'
|
|
27
|
+
this.status = status
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Add user-facing operation/path context to a filesystem failure while keeping
|
|
33
|
+
* the original error type (`SshError` code, Node `code`). B6/B12: a bare
|
|
34
|
+
* `Failure` or `No such file` from SFTP tells the user nothing.
|
|
35
|
+
*/
|
|
36
|
+
function contextualFileError(operation, remotePath, error) {
|
|
37
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
38
|
+
const label = typeof remotePath === 'string' && remotePath ? ` "${remotePath}"` : ''
|
|
39
|
+
const message = `${operation}${label} failed: ${detail}`
|
|
40
|
+
if (error instanceof SshError) {
|
|
41
|
+
const wrapped = new SshError(error.code, message)
|
|
42
|
+
if (error.fingerprint !== undefined) wrapped.fingerprint = error.fingerprint
|
|
43
|
+
return wrapped
|
|
44
|
+
}
|
|
45
|
+
if (error instanceof HttpStatusError) return error
|
|
46
|
+
const wrapped = new Error(message)
|
|
47
|
+
wrapped.cause = error
|
|
48
|
+
if (error && typeof error === 'object' && typeof error.code === 'string') wrapped.code = error.code
|
|
49
|
+
return wrapped
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Exact routes exposed to the Desktop Connection Fetch carrier. */
|
|
53
|
+
export const FETCH_ROUTES = [
|
|
54
|
+
{ path: `${PREFIX}/servers`, methods: ['GET'] },
|
|
55
|
+
{ path: `${PREFIX}/state`, methods: ['GET'] },
|
|
56
|
+
{ path: `${PREFIX}/known-hosts`, methods: ['GET'] },
|
|
57
|
+
{ path: `${PREFIX}/target`, methods: ['GET'] },
|
|
58
|
+
{ path: `${PREFIX}/activity`, methods: ['GET'] },
|
|
59
|
+
{ path: `${PREFIX}/activity/clear`, methods: ['POST'] },
|
|
60
|
+
{ path: `${PREFIX}/prefs`, methods: ['GET', 'POST'] },
|
|
61
|
+
{ path: `${PREFIX}/terminal/open`, methods: ['POST'] },
|
|
62
|
+
{ path: `${PREFIX}/terminal/input`, methods: ['POST'] },
|
|
63
|
+
{ path: `${PREFIX}/terminal/resize`, methods: ['POST'] },
|
|
64
|
+
{ path: `${PREFIX}/terminal/close`, methods: ['POST'] },
|
|
65
|
+
{ path: `${PREFIX}/terminal/read`, methods: ['GET'] },
|
|
66
|
+
{ path: `${PREFIX}/localfs/root`, methods: ['GET'] },
|
|
67
|
+
{ path: `${PREFIX}/sftp/home`, methods: ['GET', 'POST'] },
|
|
68
|
+
{ path: `${PREFIX}/server/create`, methods: ['POST'] },
|
|
69
|
+
{ path: `${PREFIX}/server/update`, methods: ['POST'] },
|
|
70
|
+
{ path: `${PREFIX}/server/remove`, methods: ['POST'] },
|
|
71
|
+
{ path: `${PREFIX}/server/test`, methods: ['POST'] },
|
|
72
|
+
{ path: `${PREFIX}/server/warm`, methods: ['POST'] },
|
|
73
|
+
{ path: `${PREFIX}/server/trust`, methods: ['POST'] },
|
|
74
|
+
{ path: `${PREFIX}/server/credential`, methods: ['POST'] },
|
|
75
|
+
{ path: `${PREFIX}/sftp/list`, methods: ['POST'] },
|
|
76
|
+
{ path: `${PREFIX}/sftp/read`, methods: ['POST'] },
|
|
77
|
+
{ path: `${PREFIX}/sftp/open-write`, methods: ['POST'] },
|
|
78
|
+
{ path: `${PREFIX}/sftp/write-chunk`, methods: ['POST'] },
|
|
79
|
+
{ path: `${PREFIX}/sftp/close-write`, methods: ['POST'] },
|
|
80
|
+
{ path: `${PREFIX}/sftp/open-read`, methods: ['POST'] },
|
|
81
|
+
{ path: `${PREFIX}/sftp/read-chunk`, methods: ['POST'] },
|
|
82
|
+
{ path: `${PREFIX}/sftp/close-read`, methods: ['POST'] },
|
|
83
|
+
{ path: `${PREFIX}/sftp/mkdir`, methods: ['POST'] },
|
|
84
|
+
{ path: `${PREFIX}/sftp/remove`, methods: ['POST'] },
|
|
85
|
+
{ path: `${PREFIX}/sftp/rename`, methods: ['POST'] },
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
function readBody(req) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const chunks = []
|
|
91
|
+
let size = 0
|
|
92
|
+
req.on('data', (chunk) => {
|
|
93
|
+
size += chunk.length
|
|
94
|
+
if (size > MAX_BODY) {
|
|
95
|
+
// 413, matching the Desktop Connection Fetch carrier below.
|
|
96
|
+
reject(new HttpStatusError(413, 'request body too large'))
|
|
97
|
+
req.destroy()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
chunks.push(chunk)
|
|
101
|
+
})
|
|
102
|
+
req.on('end', () => {
|
|
103
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
104
|
+
if (!text.trim()) return resolve({})
|
|
105
|
+
try {
|
|
106
|
+
resolve(JSON.parse(text))
|
|
107
|
+
} catch (error) {
|
|
108
|
+
reject(new Error(`invalid JSON body: ${error instanceof Error ? error.message : String(error)}`))
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
req.on('error', reject)
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function send(res, status, body, headers = {}) {
|
|
116
|
+
const text = JSON.stringify(body)
|
|
117
|
+
res.statusCode = status
|
|
118
|
+
res.setHeader?.('content-type', 'application/json; charset=utf-8')
|
|
119
|
+
res.setHeader?.('cache-control', 'no-store')
|
|
120
|
+
for (const [key, value] of Object.entries(headers)) res.setHeader?.(key, value)
|
|
121
|
+
res.end(text)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function errorBody(error) {
|
|
125
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parsePath(url) {
|
|
129
|
+
const parsed = new URL(url, 'http://localhost')
|
|
130
|
+
return { pathname: parsed.pathname, searchParams: parsed.searchParams }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveRef(value) {
|
|
134
|
+
return typeof value === 'string' && value ? value : undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {object} deps
|
|
139
|
+
* @param {() => object[]} deps.loadServers
|
|
140
|
+
* @param {(servers: object[]) => object[]} deps.saveServers
|
|
141
|
+
* @param {object} deps.runtime
|
|
142
|
+
* @param {object} deps.secrets
|
|
143
|
+
* @param {object} deps.knownHosts
|
|
144
|
+
* @param {{ load: () => object, save: (patch: object) => object }} [deps.prefs]
|
|
145
|
+
* @param {object} [deps.terminals] interactive terminal hub
|
|
146
|
+
* @param {object} [deps.localfs] local filesystem browser
|
|
147
|
+
* @param {() => object} deps.publicConfig
|
|
148
|
+
* @param {{ append: Function, list: Function }} [deps.activity] bounded local activity store
|
|
149
|
+
*/
|
|
150
|
+
export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, refreshFacts, prefs, terminals, localfs, publicConfig, activity }) {
|
|
151
|
+
const prefsApi = prefs ?? {
|
|
152
|
+
load: () => ({ sidebar: true }),
|
|
153
|
+
save: () => ({ sidebar: true }),
|
|
154
|
+
}
|
|
155
|
+
// Reuse the files panel for the local workspace by giving it the same
|
|
156
|
+
// method surface as the SSH runtime under the synthetic `__local__` ref.
|
|
157
|
+
const localFiles = localfs ? createLocalFileApi(localfs) : null
|
|
158
|
+
// Small UI-facing caches: a repeated directory view or connection test should
|
|
159
|
+
// not pay another round trip. Write operations invalidate the affected server.
|
|
160
|
+
const TEST_TTL_MS = 30000
|
|
161
|
+
const DIR_TTL_MS = 5000
|
|
162
|
+
const testCache = new Map()
|
|
163
|
+
const dirCache = new Map()
|
|
164
|
+
const dirKey = (server, path) => `${server.id}:${path}`
|
|
165
|
+
const clearDirCache = (serverId) => {
|
|
166
|
+
for (const key of [...dirCache.keys()]) if (key.startsWith(`${serverId}:`)) dirCache.delete(key)
|
|
167
|
+
}
|
|
168
|
+
const invalidateServer = (serverId) => {
|
|
169
|
+
testCache.delete(serverId)
|
|
170
|
+
clearDirCache(serverId)
|
|
171
|
+
try {
|
|
172
|
+
runtime.invalidate?.(serverId)
|
|
173
|
+
} catch {
|
|
174
|
+
// pool invalidation is best effort
|
|
175
|
+
}
|
|
176
|
+
// A server edit/removal must not leave uploads/downloads writing through a
|
|
177
|
+
// connection to the old host/credentials.
|
|
178
|
+
try {
|
|
179
|
+
void Promise.resolve(runtime.closeTransferSessionsForServer?.(serverId)).catch(() => {})
|
|
180
|
+
} catch {
|
|
181
|
+
// session cleanup is best effort
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function stateRows(servers) {
|
|
186
|
+
return servers.map((server) => {
|
|
187
|
+
const hasSecret =
|
|
188
|
+
server.authRef.type === 'password' ? secrets.has(`server:${server.id}:password`) : true
|
|
189
|
+
return {
|
|
190
|
+
id: server.id,
|
|
191
|
+
name: server.name,
|
|
192
|
+
host: server.host,
|
|
193
|
+
port: server.port,
|
|
194
|
+
username: server.username,
|
|
195
|
+
platform: server.platform,
|
|
196
|
+
dialect: effectiveDialect(server),
|
|
197
|
+
tags: server.tags,
|
|
198
|
+
description: server.description,
|
|
199
|
+
defaultShell: server.defaultShell,
|
|
200
|
+
enabled: server.enabled,
|
|
201
|
+
keyPath: server.authRef.type === 'sshKey' ? server.authRef.path : undefined,
|
|
202
|
+
authType: server.authRef.type,
|
|
203
|
+
credentialConfigured: hasSecret,
|
|
204
|
+
hostKeyKnown: knownHosts.has(server),
|
|
205
|
+
hostname: server.facts?.hostname,
|
|
206
|
+
facts: server.facts
|
|
207
|
+
? {
|
|
208
|
+
hostname: server.facts.hostname,
|
|
209
|
+
kernel: server.facts.kernel,
|
|
210
|
+
uptime: server.facts.uptime,
|
|
211
|
+
rootDisk: server.facts.rootDisk,
|
|
212
|
+
collectedAt: server.facts.collectedAt,
|
|
213
|
+
}
|
|
214
|
+
: undefined,
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function findTarget(servers, ref) {
|
|
220
|
+
return servers.find((server) => server.id === ref || server.name === ref)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function logActivity(entry) {
|
|
224
|
+
try {
|
|
225
|
+
activity?.append(entry)
|
|
226
|
+
} catch {
|
|
227
|
+
// activity recording is best effort
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Wrap one filesystem operation so failures name the operation and path. */
|
|
232
|
+
async function fileOp(operation, remotePath, action) {
|
|
233
|
+
try {
|
|
234
|
+
return await action()
|
|
235
|
+
} catch (error) {
|
|
236
|
+
throw contextualFileError(operation, remotePath, error)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Local "home" for the files panel: the canonical workspace root.
|
|
242
|
+
*
|
|
243
|
+
* `localFileApi.sftpHome()` is the intended interface (added by the localfs
|
|
244
|
+
* workstream); fall back to `localfs.rootReal()` and finally the lexical
|
|
245
|
+
* `localfs.root()` so this route keeps working while the two land.
|
|
246
|
+
*/
|
|
247
|
+
async function localHomeDirectory() {
|
|
248
|
+
const sources = []
|
|
249
|
+
if (typeof localFiles?.sftpHome === 'function') sources.push(() => localFiles.sftpHome())
|
|
250
|
+
if (typeof localfs?.rootReal === 'function') sources.push(() => localfs.rootReal())
|
|
251
|
+
if (typeof localfs?.root === 'function') sources.push(() => localfs.root())
|
|
252
|
+
if (sources.length === 0) throw new Error('local files are unavailable on this host')
|
|
253
|
+
let lastMessage
|
|
254
|
+
for (const source of sources) {
|
|
255
|
+
try {
|
|
256
|
+
const value = await source()
|
|
257
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
258
|
+
lastMessage = 'the local workspace root is empty'
|
|
259
|
+
} catch (error) {
|
|
260
|
+
lastMessage = error instanceof Error ? error.message : String(error)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`the local workspace root is unavailable${lastMessage ? `: ${lastMessage}` : ''}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function remoteHomeDirectory(server) {
|
|
267
|
+
if (typeof runtime.sftpHome !== 'function') {
|
|
268
|
+
throw new Error('home directory lookup is unavailable on this host')
|
|
269
|
+
}
|
|
270
|
+
const home = await runtime.sftpHome(server)
|
|
271
|
+
if (typeof home !== 'string' || !home.trim()) throw new Error('the server returned an empty home directory')
|
|
272
|
+
return home
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** GET/POST /sftp/home → `{ ok:true, result:{ home } }` (U5). */
|
|
276
|
+
async function serveSftpHome(res, ref) {
|
|
277
|
+
if (!ref) return send(res, 400, { ok: false, error: 'ref is required' })
|
|
278
|
+
try {
|
|
279
|
+
if (ref === LOCAL_FILE_REF) {
|
|
280
|
+
const home = await localHomeDirectory()
|
|
281
|
+
return send(res, 200, { ok: true, result: { home } })
|
|
282
|
+
}
|
|
283
|
+
const server = findTarget(loadServers(), ref)
|
|
284
|
+
if (!server) return send(res, 404, { ok: false, error: `unknown server "${ref}"` })
|
|
285
|
+
const home = await remoteHomeDirectory(server)
|
|
286
|
+
return send(res, 200, { ok: true, result: { home } })
|
|
287
|
+
} catch (error) {
|
|
288
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
289
|
+
return send(res, 502, { ok: false, error: `could not resolve the home directory for "${ref}": ${detail}` })
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function handle(req, res) {
|
|
294
|
+
if (req.method === 'OPTIONS') return send(res, 204, {})
|
|
295
|
+
const { pathname, searchParams } = parsePath(req.url)
|
|
296
|
+
const rel = pathname.startsWith(PREFIX) ? pathname.slice(PREFIX.length) || '/' : pathname
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
if (req.method === 'GET' && rel === '/servers') {
|
|
300
|
+
return send(res, 200, { ok: true, servers: loadServers() })
|
|
301
|
+
}
|
|
302
|
+
if (req.method === 'GET' && rel === '/state') {
|
|
303
|
+
return send(res, 200, { ok: true, servers: stateRows(loadServers()), config: publicConfig() })
|
|
304
|
+
}
|
|
305
|
+
if (req.method === 'GET' && rel === '/known-hosts') {
|
|
306
|
+
return send(res, 200, { ok: true, entries: knownHosts.list() })
|
|
307
|
+
}
|
|
308
|
+
if (req.method === 'GET' && rel === '/target') {
|
|
309
|
+
const session = String(searchParams.get('session') ?? '')
|
|
310
|
+
const state = session && typeof targets?.get === 'function' ? targets.get(session) : undefined
|
|
311
|
+
return send(res, 200, {
|
|
312
|
+
ok: true,
|
|
313
|
+
target: state?.active ?? null,
|
|
314
|
+
candidates: state?.candidates ?? [],
|
|
315
|
+
version: state?.version ?? 0,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
if (req.method === 'GET' && rel === '/activity') {
|
|
319
|
+
const entries = typeof activity?.list === 'function' ? activity.list(30) : []
|
|
320
|
+
return send(res, 200, { ok: true, entries })
|
|
321
|
+
}
|
|
322
|
+
if (req.method === 'POST' && rel === '/activity/clear') {
|
|
323
|
+
const cleared = typeof activity?.clear === 'function' ? activity.clear() : false
|
|
324
|
+
return send(res, 200, { ok: true, cleared })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (req.method === 'GET' && rel === '/prefs') {
|
|
328
|
+
return send(res, 200, { ok: true, prefs: prefsApi.load() })
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (req.method === 'GET' && rel === '/terminal/read') {
|
|
332
|
+
const session = String(searchParams.get('session') ?? '')
|
|
333
|
+
if (!session) return send(res, 400, { ok: false, error: 'session is required' })
|
|
334
|
+
if (!terminals) return send(res, 503, { ok: false, error: 'terminals are unavailable on this host' })
|
|
335
|
+
const cursor = Number.parseInt(searchParams.get('cursor') ?? '0', 10)
|
|
336
|
+
const waitMs = Number.parseInt(searchParams.get('wait') ?? '0', 10)
|
|
337
|
+
return send(res, 200, { ok: true, result: await terminals.read(session, { cursor, waitMs }) })
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (req.method === 'GET' && rel === '/localfs/root') {
|
|
341
|
+
if (!localfs) return send(res, 503, { ok: false, error: 'local files are unavailable on this host' })
|
|
342
|
+
// S3: list()/entry paths are canonicalized through realpath, so the
|
|
343
|
+
// navigation root handed to the client must use the same form.
|
|
344
|
+
const root = typeof localfs.rootReal === 'function'
|
|
345
|
+
? await localfs.rootReal()
|
|
346
|
+
: await Promise.resolve(localfs.root())
|
|
347
|
+
return send(res, 200, { ok: true, result: { root } })
|
|
348
|
+
}
|
|
349
|
+
if (req.method === 'GET' && rel === '/sftp/home') {
|
|
350
|
+
const ref = resolveRef(searchParams.get('ref') ?? searchParams.get('server') ?? searchParams.get('name'))
|
|
351
|
+
return await serveSftpHome(res, ref)
|
|
352
|
+
}
|
|
353
|
+
if (req.method !== 'POST') return send(res, 404, { ok: false, error: 'not found' })
|
|
354
|
+
const body = await readBody(req)
|
|
355
|
+
|
|
356
|
+
if (rel === '/sftp/home') {
|
|
357
|
+
const ref = resolveRef(
|
|
358
|
+
body.ref ?? body.server ?? body.name ?? searchParams.get('ref') ?? searchParams.get('server') ?? searchParams.get('name'),
|
|
359
|
+
)
|
|
360
|
+
return await serveSftpHome(res, ref)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (rel === '/prefs') {
|
|
364
|
+
if (typeof body.sidebar !== 'boolean') {
|
|
365
|
+
return send(res, 400, { ok: false, error: 'prefs.sidebar must be a boolean' })
|
|
366
|
+
}
|
|
367
|
+
return send(res, 200, { ok: true, prefs: prefsApi.save({ sidebar: body.sidebar }) })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (rel === '/terminal/open') {
|
|
371
|
+
if (!terminals) return send(res, 503, { ok: false, error: 'terminals are unavailable on this host' })
|
|
372
|
+
const kind = body.kind === 'remote' ? 'remote' : 'local'
|
|
373
|
+
let server
|
|
374
|
+
if (kind === 'remote') {
|
|
375
|
+
server = findTarget(loadServers(), body.ref)
|
|
376
|
+
if (!server) return send(res, 404, { ok: false, error: `unknown resource "${String(body.ref ?? '')}"` })
|
|
377
|
+
if (server.enabled === false) return send(res, 400, { ok: false, error: `resource "${server.name}" is disabled` })
|
|
378
|
+
}
|
|
379
|
+
const opened = await terminals.open({
|
|
380
|
+
kind,
|
|
381
|
+
server,
|
|
382
|
+
cols: body.cols,
|
|
383
|
+
rows: body.rows,
|
|
384
|
+
cwd: typeof body.cwd === 'string'
|
|
385
|
+
? body.cwd
|
|
386
|
+
: (kind === 'local' && localfs ? await Promise.resolve(localfs.root()) : undefined),
|
|
387
|
+
})
|
|
388
|
+
return send(res, 200, { ok: true, result: opened })
|
|
389
|
+
}
|
|
390
|
+
if (rel === '/terminal/input') {
|
|
391
|
+
if (!terminals) return send(res, 503, { ok: false, error: 'terminals are unavailable on this host' })
|
|
392
|
+
const accepted = terminals.input(String(body.session ?? ''), String(body.data ?? ''))
|
|
393
|
+
return send(res, accepted ? 200 : 404, { ok: accepted, accepted })
|
|
394
|
+
}
|
|
395
|
+
if (rel === '/terminal/resize') {
|
|
396
|
+
if (!terminals) return send(res, 503, { ok: false, error: 'terminals are unavailable on this host' })
|
|
397
|
+
const accepted = terminals.resize(String(body.session ?? ''), body.cols, body.rows)
|
|
398
|
+
return send(res, accepted ? 200 : 404, { ok: accepted, accepted })
|
|
399
|
+
}
|
|
400
|
+
if (rel === '/terminal/close') {
|
|
401
|
+
if (!terminals) return send(res, 200, { ok: true, closed: false })
|
|
402
|
+
return send(res, 200, { ok: true, closed: terminals.close(String(body.session ?? '')) })
|
|
403
|
+
}
|
|
404
|
+
if (rel === '/server/create') {
|
|
405
|
+
const record = body.record ?? body
|
|
406
|
+
const saved = saveServers([...loadServers(), record])
|
|
407
|
+
logActivity({ kind: 'server-create', server: String(record.name ?? ''), outcome: 'saved' })
|
|
408
|
+
return send(res, 200, { ok: true, server: saved.find((server) => server.name === record.name) ?? saved.at(-1) })
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Chunked transfer sessions: the client holds a session id, so these
|
|
412
|
+
// routes do not take a server ref. They must run before ref resolution.
|
|
413
|
+
const sessionId = typeof body.session === 'string' ? body.session : ''
|
|
414
|
+
const localTransfer = localFiles && sessionId.startsWith('loc') ? localFiles : null
|
|
415
|
+
if (rel === '/sftp/write-chunk') {
|
|
416
|
+
if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
|
|
417
|
+
// B10: offset used to be silently coerced to 0 by the runtime, which
|
|
418
|
+
// could corrupt a resumed upload; it must be an explicit non-negative
|
|
419
|
+
// integer from the client.
|
|
420
|
+
if (!Number.isInteger(body.offset) || body.offset < 0) {
|
|
421
|
+
return send(res, 400, { ok: false, error: 'offset must be a non-negative integer' })
|
|
422
|
+
}
|
|
423
|
+
if (typeof body.contentBase64 !== 'string' || !body.contentBase64) {
|
|
424
|
+
return send(res, 400, { ok: false, error: 'contentBase64 is required' })
|
|
425
|
+
}
|
|
426
|
+
const buffer = Buffer.from(body.contentBase64, 'base64')
|
|
427
|
+
if (buffer.length > 4 * 1024 * 1024) return send(res, 413, { ok: false, error: 'chunk too large (4 MiB limit)' })
|
|
428
|
+
const api = localTransfer ?? runtime
|
|
429
|
+
const result = await fileOp('write chunk', undefined, () => api.sftpWriteChunk(sessionId, body.offset, buffer))
|
|
430
|
+
return send(res, 200, { ok: true, result })
|
|
431
|
+
}
|
|
432
|
+
if (rel === '/sftp/close-write') {
|
|
433
|
+
if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
|
|
434
|
+
const api = localTransfer ?? runtime
|
|
435
|
+
if (body.abort === true) {
|
|
436
|
+
const result = await fileOp('abort upload', undefined, () => api.sftpCloseTransfer(sessionId, { verify: false }))
|
|
437
|
+
return send(res, 200, { ok: true, result })
|
|
438
|
+
}
|
|
439
|
+
const result = await fileOp('finish upload', undefined, () => api.sftpCloseTransfer(sessionId, { verify: true }))
|
|
440
|
+
// F1/B5/B8: the local adapter used to return no `server`, so the dir
|
|
441
|
+
// cache was cleared for `undefined` and the activity log said
|
|
442
|
+
// "unknown". Prefer the reported id, fall back to the synthetic local
|
|
443
|
+
// ref for `loc*` sessions (LOCAL_FILE_REF is the intended value once
|
|
444
|
+
// local-file-api reports it).
|
|
445
|
+
const serverId = typeof result?.server === 'string' && result.server
|
|
446
|
+
? result.server
|
|
447
|
+
: (localTransfer ? LOCAL_FILE_REF : undefined)
|
|
448
|
+
const inventory = loadServers()
|
|
449
|
+
const serverName = localTransfer
|
|
450
|
+
? LOCAL_FILE_REF
|
|
451
|
+
: (inventory.find((entry) => entry.id === serverId)?.name ?? serverId ?? 'unknown')
|
|
452
|
+
if (serverId) clearDirCache(serverId)
|
|
453
|
+
logActivity({ kind: 'file-write', server: serverName, summary: `${result?.path ?? ''} (${result?.bytes ?? 0} bytes)`, outcome: 'success' })
|
|
454
|
+
return send(res, 200, { ok: true, result })
|
|
455
|
+
}
|
|
456
|
+
if (rel === '/sftp/read-chunk') {
|
|
457
|
+
if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
|
|
458
|
+
if (!Number.isInteger(body.offset) || body.offset < 0) {
|
|
459
|
+
return send(res, 400, { ok: false, error: 'offset must be a non-negative integer' })
|
|
460
|
+
}
|
|
461
|
+
const length = Number.isInteger(body.length) ? body.length : 2 * 1024 * 1024
|
|
462
|
+
if (length <= 0 || length > 4 * 1024 * 1024) return send(res, 400, { ok: false, error: 'length must be 1..4 MiB' })
|
|
463
|
+
const api = localTransfer ?? runtime
|
|
464
|
+
const result = await fileOp('read chunk', undefined, () => api.sftpReadChunk(sessionId, body.offset, length))
|
|
465
|
+
return send(res, 200, { ok: true, result })
|
|
466
|
+
}
|
|
467
|
+
if (rel === '/sftp/close-read') {
|
|
468
|
+
if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
|
|
469
|
+
const api = localTransfer ?? runtime
|
|
470
|
+
const result = await fileOp('close download', undefined, () => api.sftpCloseTransfer(sessionId))
|
|
471
|
+
return send(res, 200, { ok: true, result })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const ref = resolveRef(body.ref ?? body.server ?? body.name)
|
|
475
|
+
const servers = loadServers()
|
|
476
|
+
let target
|
|
477
|
+
let fileApi = runtime
|
|
478
|
+
if (ref === LOCAL_FILE_REF && localFiles) {
|
|
479
|
+
target = localFiles.server
|
|
480
|
+
fileApi = localFiles
|
|
481
|
+
} else {
|
|
482
|
+
target = ref ? findTarget(servers, ref) : undefined
|
|
483
|
+
}
|
|
484
|
+
if (!target) return send(res, 404, { ok: false, error: `unknown server "${ref ?? ''}"` })
|
|
485
|
+
|
|
486
|
+
if (rel === '/server/update') {
|
|
487
|
+
const patch = body.patch ?? {}
|
|
488
|
+
const next = servers.map((server) => (server.id === target.id ? { ...server, ...patch, id: target.id } : server))
|
|
489
|
+
const saved = saveServers(next)
|
|
490
|
+
terminals?.closeForServer?.(target.id)
|
|
491
|
+
invalidateServer(target.id)
|
|
492
|
+
logActivity({ kind: 'server-update', server: target.name, outcome: 'saved' })
|
|
493
|
+
return send(res, 200, { ok: true, server: saved.find((server) => server.id === target.id) })
|
|
494
|
+
}
|
|
495
|
+
if (rel === '/server/remove') {
|
|
496
|
+
saveServers(servers.filter((server) => server.id !== target.id))
|
|
497
|
+
terminals?.closeForServer?.(target.id)
|
|
498
|
+
invalidateServer(target.id)
|
|
499
|
+
logActivity({ kind: 'server-remove', server: target.name, outcome: 'removed' })
|
|
500
|
+
return send(res, 200, { ok: true, removed: target.name })
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (rel === '/server/test') {
|
|
504
|
+
const cached = testCache.get(target.id)
|
|
505
|
+
if (cached && Date.now() - cached.at < TEST_TTL_MS && body.refresh !== true) {
|
|
506
|
+
return send(res, 200, { ok: true, result: { ...cached.result, cached: true } })
|
|
507
|
+
}
|
|
508
|
+
const result = await runtime.test(target)
|
|
509
|
+
if (result.ok) testCache.set(target.id, { at: Date.now(), result })
|
|
510
|
+
// A successful test is a good moment to refresh host facts for the UI.
|
|
511
|
+
let facts
|
|
512
|
+
if (result.ok && typeof refreshFacts === 'function') {
|
|
513
|
+
try {
|
|
514
|
+
facts = await refreshFacts(target)
|
|
515
|
+
} catch {
|
|
516
|
+
// facts are optional metadata
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
logActivity({ kind: 'test', server: target.name, outcome: result.ok ? 'ok' : (result.errorCode ?? 'failed') })
|
|
520
|
+
return send(res, 200, { ok: true, result: facts ? { ...result, facts } : result })
|
|
521
|
+
}
|
|
522
|
+
if (rel === '/server/warm') {
|
|
523
|
+
if (target.id === LOCAL_FILE_REF) return send(res, 200, { ok: true, warmed: false })
|
|
524
|
+
const config = typeof publicConfig === 'function' ? publicConfig() : undefined
|
|
525
|
+
if (config && config.prewarm === false) {
|
|
526
|
+
return send(res, 200, { ok: true, warmed: false, skipped: 'prewarm disabled' })
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
runtime.warm?.(target)
|
|
530
|
+
} catch {
|
|
531
|
+
// warm-up is best effort
|
|
532
|
+
}
|
|
533
|
+
return send(res, 200, { ok: true, warmed: target.name })
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (rel === '/server/trust') {
|
|
537
|
+
if (typeof body.fingerprint !== 'string' || !body.fingerprint) {
|
|
538
|
+
return send(res, 400, { ok: false, error: 'fingerprint is required' })
|
|
539
|
+
}
|
|
540
|
+
knownHosts.trust(target, body.fingerprint)
|
|
541
|
+
logActivity({ kind: 'trust', server: target.name, outcome: 'trusted' })
|
|
542
|
+
return send(res, 200, { ok: true })
|
|
543
|
+
}
|
|
544
|
+
if (rel === '/server/credential') {
|
|
545
|
+
if (typeof body.password === 'string' && body.password) {
|
|
546
|
+
secrets.set(`server:${target.id}:password`, body.password)
|
|
547
|
+
// The explicit "set password" action also selects password auth:
|
|
548
|
+
// otherwise the stored secret would be ignored by a key-auth record.
|
|
549
|
+
if (target.authRef.type !== 'password') {
|
|
550
|
+
saveServers(servers.map((server) => (server.id === target.id ? { ...server, authRef: { type: 'password' } } : server)))
|
|
551
|
+
logActivity({ kind: 'server-update', server: target.name, summary: 'auth -> password', outcome: 'saved' })
|
|
552
|
+
}
|
|
553
|
+
invalidateServer(target.id)
|
|
554
|
+
}
|
|
555
|
+
if (typeof body.passphrase === 'string' && body.passphrase) secrets.set(`server:${target.id}:passphrase`, body.passphrase)
|
|
556
|
+
return send(res, 200, { ok: true })
|
|
557
|
+
}
|
|
558
|
+
if (rel === '/sftp/list') {
|
|
559
|
+
if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
|
|
560
|
+
const key = dirKey(target, body.path)
|
|
561
|
+
const cached = dirCache.get(key)
|
|
562
|
+
if (body.refresh !== true && cached && Date.now() - cached.at < DIR_TTL_MS) {
|
|
563
|
+
return send(res, 200, { ok: true, result: cached.entries, cached: true })
|
|
564
|
+
}
|
|
565
|
+
const entries = await fileOp('list', body.path, () => fileApi.sftpList(target, body.path))
|
|
566
|
+
dirCache.set(key, { at: Date.now(), entries })
|
|
567
|
+
return send(res, 200, { ok: true, result: entries })
|
|
568
|
+
}
|
|
569
|
+
if (rel === '/sftp/read') {
|
|
570
|
+
if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
|
|
571
|
+
const wantsBase64 = body.base64 === true
|
|
572
|
+
const result = await fileOp('read', body.path, () => fileApi.sftpRead(target, body.path, {
|
|
573
|
+
maxBytes: Number.isInteger(body.maxBytes) ? body.maxBytes : undefined,
|
|
574
|
+
base64: wantsBase64,
|
|
575
|
+
}))
|
|
576
|
+
if (result.binary && !wantsBase64) return send(res, 200, { ok: true, result: { ...result, content: undefined } })
|
|
577
|
+
return send(res, 200, { ok: true, result })
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (rel === '/sftp/open-write') {
|
|
581
|
+
if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
|
|
582
|
+
return send(res, 200, { ok: true, result: await fileOp('open for writing', body.path, () => fileApi.sftpOpenWrite(target, body.path, body.truncate !== false)) })
|
|
583
|
+
}
|
|
584
|
+
if (rel === '/sftp/open-read') {
|
|
585
|
+
if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
|
|
586
|
+
return send(res, 200, { ok: true, result: await fileOp('open for reading', body.path, () => fileApi.sftpOpenRead(target, body.path)) })
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
if (rel === '/sftp/mkdir') {
|
|
591
|
+
if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
|
|
592
|
+
const result = await fileOp('mkdir', body.path, () => fileApi.sftpMkdir(target, body.path))
|
|
593
|
+
clearDirCache(target.id)
|
|
594
|
+
logActivity({ kind: 'file-mkdir', server: target.name, summary: body.path, outcome: 'success' })
|
|
595
|
+
return send(res, 200, { ok: true, result })
|
|
596
|
+
}
|
|
597
|
+
if (rel === '/sftp/remove') {
|
|
598
|
+
if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
|
|
599
|
+
const result = await fileOp('remove', body.path, () => fileApi.sftpRemove(target, body.path, body.isDir === true))
|
|
600
|
+
clearDirCache(target.id)
|
|
601
|
+
logActivity({ kind: 'file-remove', server: target.name, summary: body.path, outcome: 'success' })
|
|
602
|
+
return send(res, 200, { ok: true, result })
|
|
603
|
+
}
|
|
604
|
+
if (rel === '/sftp/rename') {
|
|
605
|
+
if (typeof body.path !== 'string' || typeof body.targetPath !== 'string') {
|
|
606
|
+
return send(res, 400, { ok: false, error: 'path and targetPath are required' })
|
|
607
|
+
}
|
|
608
|
+
const normalize = (value) => String(value).replace(/\/+$/, '') || '/'
|
|
609
|
+
if (normalize(body.path) === normalize(body.targetPath)) {
|
|
610
|
+
return send(res, 400, { ok: false, error: 'source and target path are the same' })
|
|
611
|
+
}
|
|
612
|
+
const result = await fileOp(`rename "${body.path}" to`, body.targetPath, () => fileApi.sftpRename(target, body.path, body.targetPath))
|
|
613
|
+
clearDirCache(target.id)
|
|
614
|
+
logActivity({ kind: 'file-rename', server: target.name, summary: `${body.path} -> ${body.targetPath}`, outcome: 'success' })
|
|
615
|
+
return send(res, 200, { ok: true, result })
|
|
616
|
+
}
|
|
617
|
+
return send(res, 404, { ok: false, error: 'not found' })
|
|
618
|
+
} catch (error) {
|
|
619
|
+
const status = error instanceof HttpStatusError ? error.status : 400
|
|
620
|
+
return send(res, status, errorBody(error))
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return handle
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Wrap one descriptor as an exact Connection Fetch route (Desktop carrier). */
|
|
628
|
+
export function connectionRoute(descriptor, handler) {
|
|
629
|
+
return {
|
|
630
|
+
path: `/api${descriptor.path}`,
|
|
631
|
+
methods: ['GET', 'POST'],
|
|
632
|
+
requestBody: 'buffered',
|
|
633
|
+
async fetch(request) {
|
|
634
|
+
const url = new URL(request.url)
|
|
635
|
+
const chunks = []
|
|
636
|
+
let size = 0
|
|
637
|
+
const reader = request.body?.getReader?.()
|
|
638
|
+
if (reader) {
|
|
639
|
+
try {
|
|
640
|
+
while (true) {
|
|
641
|
+
const { done, value } = await reader.read()
|
|
642
|
+
if (done) break
|
|
643
|
+
size += value.byteLength
|
|
644
|
+
if (size > MAX_BODY) {
|
|
645
|
+
await reader.cancel()
|
|
646
|
+
return Response.json({ ok: false, error: 'request body too large' }, { status: 413 })
|
|
647
|
+
}
|
|
648
|
+
chunks.push(Buffer.from(value))
|
|
649
|
+
}
|
|
650
|
+
} finally {
|
|
651
|
+
reader.releaseLock()
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const req = Readable.from(chunks.length ? [Buffer.concat(chunks)] : [])
|
|
655
|
+
req.method = request.method
|
|
656
|
+
req.url = descriptor.path + url.search
|
|
657
|
+
req.headers = Object.fromEntries(request.headers)
|
|
658
|
+
|
|
659
|
+
const headers = new Headers()
|
|
660
|
+
let bodyText = ''
|
|
661
|
+
const res = {
|
|
662
|
+
statusCode: 200,
|
|
663
|
+
setHeader(key, value) {
|
|
664
|
+
headers.set(key, value)
|
|
665
|
+
},
|
|
666
|
+
end(text) {
|
|
667
|
+
bodyText = text ?? ''
|
|
668
|
+
},
|
|
669
|
+
}
|
|
670
|
+
await handler(req, res)
|
|
671
|
+
return new Response(bodyText, { status: res.statusCode, headers })
|
|
672
|
+
},
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Register the data plane on whichever carriers the composition provides. */
|
|
677
|
+
export function registerRoutes(ctx, handler, { fetchRoutes = FETCH_ROUTES } = {}) {
|
|
678
|
+
if (typeof ctx.inject !== 'function') return () => {}
|
|
679
|
+
|
|
680
|
+
const fence = (req, res) => {
|
|
681
|
+
const connection = ctx.get?.('connection')
|
|
682
|
+
const rejection =
|
|
683
|
+
connection && typeof connection.requestRejection === 'function'
|
|
684
|
+
? connection.requestRejection(req)
|
|
685
|
+
: undefined
|
|
686
|
+
if (rejection !== undefined) {
|
|
687
|
+
res.statusCode = rejection
|
|
688
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
689
|
+
return false
|
|
690
|
+
}
|
|
691
|
+
return true
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
ctx.inject(['webServer'], (inner) => {
|
|
695
|
+
const webServer = inner.get('webServer') ?? inner.webServer
|
|
696
|
+
if (!webServer || typeof webServer.register !== 'function') return
|
|
697
|
+
const fenced = (req, res) => (fence(req, res) ? handler(req, res) : undefined)
|
|
698
|
+
inner.effect(
|
|
699
|
+
() => webServer.register({ kind: 'prefix', path: PREFIX, handler: fenced }),
|
|
700
|
+
'dsh-ssh-workbench: web routes',
|
|
701
|
+
)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
ctx.inject(['connection'], (inner) => {
|
|
705
|
+
const connection = inner.get('connection')
|
|
706
|
+
if (typeof connection?.fetch?.register !== 'function') return
|
|
707
|
+
const registered = []
|
|
708
|
+
for (const descriptor of fetchRoutes) {
|
|
709
|
+
try {
|
|
710
|
+
registered.push(connection.fetch.register(connectionRoute(descriptor, handler)))
|
|
711
|
+
} catch (error) {
|
|
712
|
+
ctx.logger?.warn?.('dsh-ssh-workbench: fetch route not registered', {
|
|
713
|
+
path: descriptor.path,
|
|
714
|
+
error: error instanceof Error ? error.message : String(error),
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
inner.effect(
|
|
719
|
+
() => () => registered.map((dispose) => {
|
|
720
|
+
try {
|
|
721
|
+
return dispose()
|
|
722
|
+
} catch {
|
|
723
|
+
return undefined
|
|
724
|
+
}
|
|
725
|
+
}),
|
|
726
|
+
'dsh-ssh-workbench: fetch routes',
|
|
727
|
+
)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
return () => {}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
|