@linxin666/dsh-ssh 0.1.19 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/lib/client.js +224 -93
- package/lib/client.js.map +1 -1
- package/lib/index.js +182 -91
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/panel/HostsTab.d.ts +16 -0
- package/lib/types/engine/connection-pool.d.ts +2 -0
- package/lib/types/engine/tunnel.d.ts +4 -2
- package/lib/types/engine.d.ts +7 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/routes.d.ts +2 -0
- package/lib/types/store.d.ts +6 -0
- package/package.json +12 -12
- package/src/client/locales.ts +16 -0
- package/src/client/panel/HostsTab.tsx +152 -48
- package/src/client/panel/panel.module.css +75 -0
- package/src/engine/connection-pool.ts +23 -3
- package/src/engine/sftp.ts +29 -38
- package/src/engine/tunnel.ts +19 -5
- package/src/engine.ts +11 -0
- package/src/index.ts +4 -1
- package/src/mount-once.ts +48 -0
- package/src/routes.ts +46 -5
- package/src/store.ts +21 -2
package/src/routes.ts
CHANGED
|
@@ -78,6 +78,8 @@ export interface SshRoutesDeps {
|
|
|
78
78
|
engine: SshEngine
|
|
79
79
|
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
80
80
|
stagingDir?: string
|
|
81
|
+
/** Upload byte cap override (tests); defaults to MAX_UPLOAD_BYTES. */
|
|
82
|
+
maxUploadBytes?: number
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/**
|
|
@@ -88,6 +90,7 @@ export interface SshRoutesDeps {
|
|
|
88
90
|
export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
|
|
89
91
|
const { store, engine } = deps
|
|
90
92
|
const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
|
|
93
|
+
const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
91
94
|
// The upload route stages request bodies here; it must exist before the
|
|
92
95
|
// first request (a missing dir would hang the first upload forever).
|
|
93
96
|
mkdirSync(staging, { recursive: true })
|
|
@@ -154,6 +157,13 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
154
157
|
}
|
|
155
158
|
try {
|
|
156
159
|
const entry = store.update(alias, body as unknown as Partial<HostPayload>)
|
|
160
|
+
// Connection-relevant changes invalidate the pooled connection:
|
|
161
|
+
// without this the pool would keep running commands on the old
|
|
162
|
+
// host/credentials until the idle sweep (up to 30 min later).
|
|
163
|
+
const patch = body as Record<string, unknown>
|
|
164
|
+
if (['host', 'port', 'user', 'auth', 'proxyJump'].some(key => patch[key] !== undefined)) {
|
|
165
|
+
engine.dropAlias(alias)
|
|
166
|
+
}
|
|
157
167
|
writeJson(res, 200, { host: store.summarize(entry) })
|
|
158
168
|
} catch (error) {
|
|
159
169
|
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
@@ -162,7 +172,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
162
172
|
}
|
|
163
173
|
if (method === 'DELETE') {
|
|
164
174
|
try {
|
|
165
|
-
engine.
|
|
175
|
+
engine.dropAlias(alias)
|
|
166
176
|
store.delete(alias)
|
|
167
177
|
writeJson(res, 200, { ok: true })
|
|
168
178
|
} catch (error) {
|
|
@@ -329,7 +339,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
329
339
|
return
|
|
330
340
|
}
|
|
331
341
|
const declared = Number(req.headers['content-length'])
|
|
332
|
-
if (Number.isFinite(declared) && declared >
|
|
342
|
+
if (Number.isFinite(declared) && declared > maxUploadBytes) {
|
|
333
343
|
writeJson(res, 413, { error: 'upload body too large' })
|
|
334
344
|
return
|
|
335
345
|
}
|
|
@@ -351,9 +361,22 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
351
361
|
if (settled) return
|
|
352
362
|
settled = true
|
|
353
363
|
emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
364
|
+
// End the response only after the tmp file is gone, and unlink only
|
|
365
|
+
// after the sink is fully closed: destroying a WriteStream whose
|
|
366
|
+
// fs.open is still pending lets the open RE-CREATE the file after
|
|
367
|
+
// an early unlink, leaving staging populated (this raced the
|
|
368
|
+
// response end and made the byte-cap test flaky).
|
|
369
|
+
const cleanup = (): void => {
|
|
370
|
+
void unlink(tmp).catch(() => undefined).finally(() => {
|
|
371
|
+
try { res.end() } catch { /* closed */ }
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
if (sink.destroyed) {
|
|
375
|
+
cleanup()
|
|
376
|
+
} else {
|
|
377
|
+
sink.once('close', cleanup)
|
|
378
|
+
try { sink.destroy() } catch { cleanup() }
|
|
379
|
+
}
|
|
357
380
|
}
|
|
358
381
|
const done = (): void => {
|
|
359
382
|
if (settled) return
|
|
@@ -365,6 +388,24 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
365
388
|
req.on('aborted', () => fail('upload aborted by the client'))
|
|
366
389
|
res.on('error', () => fail('response stream closed'))
|
|
367
390
|
res.on('close', () => { if (!res.writableEnded) fail('connection closed') })
|
|
391
|
+
// The content-length pre-check above can be bypassed by chunked or
|
|
392
|
+
// header-less requests: count the bytes as they actually arrive and
|
|
393
|
+
// abort the moment the cap is exceeded.
|
|
394
|
+
let received = 0
|
|
395
|
+
let capped = false
|
|
396
|
+
req.on('data', (chunk: Buffer) => {
|
|
397
|
+
received += chunk.byteLength
|
|
398
|
+
if (received > maxUploadBytes && !capped) {
|
|
399
|
+
capped = true
|
|
400
|
+
fail('upload body too large')
|
|
401
|
+
// Keep the socket alive until the response flush finishes:
|
|
402
|
+
// destroying the request here races res.end() and the client
|
|
403
|
+
// sees a hang-up instead of the result frame. Drain the rest of
|
|
404
|
+
// the body so the socket can close cleanly afterwards.
|
|
405
|
+
res.on('finish', () => { try { req.destroy() } catch { /* closed */ } })
|
|
406
|
+
req.resume()
|
|
407
|
+
}
|
|
408
|
+
})
|
|
368
409
|
req.pipe(sink)
|
|
369
410
|
sink.on('finish', async () => {
|
|
370
411
|
if (settled) return
|
package/src/store.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* as ssh-skill's annotated ssh-config comments; document it, never log it.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { dirname, join, resolve } from 'node:path'
|
|
12
12
|
import type { HostPayload, ImportResult, SshHostEntry, SshHostSummary } from './protocol.ts'
|
|
@@ -306,18 +306,36 @@ export class HostStore {
|
|
|
306
306
|
|
|
307
307
|
private skippedNames = new Set<string>()
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
311
|
+
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
312
|
+
* wasted work when the file has not changed. Any save invalidates.
|
|
313
|
+
*/
|
|
314
|
+
private cache: { mtimeMs: number; size: number; file: StoreFile } | undefined
|
|
315
|
+
|
|
309
316
|
private load(): StoreFile {
|
|
310
|
-
|
|
317
|
+
let stats: { mtimeMs: number; size: number }
|
|
318
|
+
try {
|
|
319
|
+
stats = statSync(this.path)
|
|
320
|
+
} catch {
|
|
321
|
+
this.cache = undefined
|
|
322
|
+
return { version: FORMAT_VERSION, hosts: [] }
|
|
323
|
+
}
|
|
324
|
+
if (this.cache !== undefined && this.cache.mtimeMs === stats.mtimeMs && this.cache.size === stats.size) {
|
|
325
|
+
return this.cache.file
|
|
326
|
+
}
|
|
311
327
|
try {
|
|
312
328
|
const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as StoreFile
|
|
313
329
|
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) {
|
|
314
330
|
throw new Error('store file shape invalid')
|
|
315
331
|
}
|
|
332
|
+
this.cache = { mtimeMs: stats.mtimeMs, size: stats.size, file: parsed }
|
|
316
333
|
return parsed
|
|
317
334
|
} catch {
|
|
318
335
|
// A corrupt store must not brick the plugin — and must not be silently
|
|
319
336
|
// overwritten by the next save: rename it aside for manual recovery
|
|
320
337
|
// (the plugin then starts from an empty list).
|
|
338
|
+
this.cache = undefined
|
|
321
339
|
try {
|
|
322
340
|
renameSync(this.path, `${this.path}.corrupt-${Date.now()}`)
|
|
323
341
|
} catch { /* best effort */ }
|
|
@@ -333,6 +351,7 @@ export class HostStore {
|
|
|
333
351
|
// tmp file carries the 0600 mode through the rename.
|
|
334
352
|
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
335
353
|
renameSync(tmp, this.path)
|
|
354
|
+
this.cache = undefined
|
|
336
355
|
}
|
|
337
356
|
}
|
|
338
357
|
|