@linxin666/dsh-ssh 0.1.18 → 0.1.20
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 +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +14 -11
- package/lib/client.js.map +1 -1
- package/lib/index.js +211 -111
- package/lib/types/client/locales.d.ts +127 -127
- package/lib/types/engine/connection-pool.d.ts +3 -1
- 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/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/protocol.d.ts +1 -1
- package/lib/types/routes.d.ts +2 -0
- package/lib/types/store.d.ts +6 -0
- package/package.json +1 -1
- package/src/client/locales.ts +2 -2
- package/src/client/panel/SshPanel.tsx +9 -1
- package/src/client/panel/panel.module.css +6 -0
- package/src/engine/connection-pool.ts +28 -8
- 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/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/routes.ts +24 -25
- package/src/store.ts +21 -2
package/src/engine/sftp.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* released exactly once so sshd's MaxSessions cap is never exhausted.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
7
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
8
8
|
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
9
9
|
import { Client, type SFTPWrapper } from 'ssh2'
|
|
10
10
|
import type { RemoteDirEntry, TransferProgress } from '../protocol.ts'
|
|
@@ -16,7 +16,11 @@ export function walkLocalDir(root: string): string[] {
|
|
|
16
16
|
const visit = (dir: string): void => {
|
|
17
17
|
for (const name of readdirSync(dir)) {
|
|
18
18
|
const full = join(dir, name)
|
|
19
|
-
|
|
19
|
+
// lstat, not stat: a symlink must never be followed — a link cycle
|
|
20
|
+
// (ln -s . self) recurses forever under stat, and a link to a file
|
|
21
|
+
// would silently upload the target's bytes.
|
|
22
|
+
const stat = lstatSync(full)
|
|
23
|
+
if (stat.isSymbolicLink()) continue
|
|
20
24
|
if (stat.isDirectory()) visit(full)
|
|
21
25
|
else if (stat.isFile()) files.push(relative(root, full))
|
|
22
26
|
}
|
|
@@ -169,13 +173,20 @@ function ensureRemoteDir(sftp: SFTPWrapper, remote: string): Promise<void> {
|
|
|
169
173
|
})
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
|
|
176
|
+
/** One fastPut/fastGet transfer with throttled progress (the two directions share everything but the verb). */
|
|
177
|
+
function fastTransfer(sftp: SFTPWrapper, kind: 'put' | 'get', src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
173
178
|
return new Promise((resolve, reject) => {
|
|
179
|
+
// Progress frames name the destination on upload, the source on download;
|
|
180
|
+
// the final size comes from the local side of the transfer.
|
|
181
|
+
const file = kind === 'put' ? dst : src
|
|
182
|
+
const finalSize = (): number => statSync(kind === 'put' ? src : dst).size
|
|
174
183
|
let last = 0
|
|
175
184
|
let lastEmit = 0
|
|
176
185
|
const started = Date.now()
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
if (kind === 'put') {
|
|
187
|
+
onProgress?.({ phase: 'transferring', file, transferred: 0, total: statSync(src).size, percent: 0 })
|
|
188
|
+
}
|
|
189
|
+
const step = (transferred: number, _chunk: number, total: number): void => {
|
|
179
190
|
const now = Date.now()
|
|
180
191
|
// Throttle: high-speed links fire one callback per chunk; the UI only
|
|
181
192
|
// needs ~10 frames per second.
|
|
@@ -184,52 +195,32 @@ function fastPut(sftp: SFTPWrapper, src: string, dst: string, concurrency: numbe
|
|
|
184
195
|
const elapsed = (now - started) / 1000
|
|
185
196
|
onProgress?.({
|
|
186
197
|
phase: 'transferring',
|
|
187
|
-
file
|
|
198
|
+
file,
|
|
188
199
|
transferred,
|
|
189
200
|
total,
|
|
190
201
|
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
191
202
|
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
192
203
|
})
|
|
193
204
|
last = transferred
|
|
194
|
-
}
|
|
205
|
+
}
|
|
206
|
+
const done = (error: unknown): void => {
|
|
195
207
|
if (error !== undefined) {
|
|
196
|
-
onProgress?.({ phase: 'error', file
|
|
208
|
+
onProgress?.({ phase: 'error', file, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
197
209
|
reject(error)
|
|
198
210
|
} else {
|
|
199
|
-
onProgress?.({ phase: 'done', file
|
|
211
|
+
onProgress?.({ phase: 'done', file, transferred: finalSize(), total: finalSize(), percent: 100 })
|
|
200
212
|
resolve()
|
|
201
213
|
}
|
|
202
|
-
}
|
|
214
|
+
}
|
|
215
|
+
if (kind === 'put') sftp.fastPut(src, dst, { concurrency, step }, done)
|
|
216
|
+
else sftp.fastGet(src, dst, { concurrency, step }, done)
|
|
203
217
|
})
|
|
204
218
|
}
|
|
205
219
|
|
|
220
|
+
function fastPut(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
221
|
+
return fastTransfer(sftp, 'put', src, dst, concurrency, onProgress)
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
function fastGet(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
207
|
-
return
|
|
208
|
-
let last = 0
|
|
209
|
-
let lastEmit = 0
|
|
210
|
-
const started = Date.now()
|
|
211
|
-
sftp.fastGet(src, dst, { concurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
212
|
-
const now = Date.now()
|
|
213
|
-
if (now - lastEmit < 100 && transferred < total) return
|
|
214
|
-
lastEmit = now
|
|
215
|
-
const elapsed = (now - started) / 1000
|
|
216
|
-
onProgress?.({
|
|
217
|
-
phase: 'transferring',
|
|
218
|
-
file: src,
|
|
219
|
-
transferred,
|
|
220
|
-
total,
|
|
221
|
-
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
222
|
-
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
223
|
-
})
|
|
224
|
-
last = transferred
|
|
225
|
-
} }, (error) => {
|
|
226
|
-
if (error !== undefined) {
|
|
227
|
-
onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
228
|
-
reject(error)
|
|
229
|
-
} else {
|
|
230
|
-
onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
|
|
231
|
-
resolve()
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
})
|
|
225
|
+
return fastTransfer(sftp, 'get', src, dst, concurrency, onProgress)
|
|
235
226
|
}
|
package/src/engine/tunnel.ts
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
import { createServer, type Server as NetServer, type Socket } from 'node:net'
|
|
8
8
|
import type { TunnelInfo } from '../protocol.ts'
|
|
9
|
-
import { acquire, disposeRecord, type PoolEngine } from './connection-pool.ts'
|
|
9
|
+
import { acquire, disposeRecord, endRecordChain, type PoolEngine, type PoolRecord } from './connection-pool.ts'
|
|
10
10
|
|
|
11
|
-
/** One active tunnel record (server + pinned
|
|
11
|
+
/** One active tunnel record (server + pinned connection + live sockets). */
|
|
12
12
|
export interface TunnelRecord {
|
|
13
13
|
info: TunnelInfo
|
|
14
14
|
server: NetServer
|
|
15
15
|
alias: string
|
|
16
|
+
/** The pooled connection this tunnel pins; siblings on one alias may share it. */
|
|
17
|
+
record: PoolRecord
|
|
16
18
|
sockets: Set<Socket>
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -48,7 +50,12 @@ export async function startTunnel(
|
|
|
48
50
|
state: 'connecting',
|
|
49
51
|
startedAt: Date.now(),
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
// Reuse the live pooled connection when one exists so sibling tunnels on
|
|
54
|
+
// one alias multiplex over it (and no orphaned connection leaks); recycle
|
|
55
|
+
// a broken one first.
|
|
56
|
+
const existing = engine.pool.get(alias)
|
|
57
|
+
if (existing !== undefined && existing.broken) disposeRecord(engine, alias, existing)
|
|
58
|
+
const record = engine.pool.get(alias) ?? (await acquire(engine, alias))
|
|
52
59
|
const client = record.client
|
|
53
60
|
const sockets = new Set<Socket>()
|
|
54
61
|
const server = createServer((socket) => {
|
|
@@ -89,7 +96,7 @@ export async function startTunnel(
|
|
|
89
96
|
const address = server.address()
|
|
90
97
|
info.localPort = typeof address === 'object' && address !== null ? address.port : 0
|
|
91
98
|
info.state = 'forwarding'
|
|
92
|
-
engine.tunnels.set(id, { info, server, alias, sockets })
|
|
99
|
+
engine.tunnels.set(id, { info, server, alias, record, sockets })
|
|
93
100
|
return info
|
|
94
101
|
}
|
|
95
102
|
|
|
@@ -108,7 +115,14 @@ export function stopTunnel(engine: TunnelEngine, id: string): boolean {
|
|
|
108
115
|
try { socket.destroy() } catch { /* already closed */ }
|
|
109
116
|
}
|
|
110
117
|
tunnel.sockets.clear()
|
|
111
|
-
|
|
118
|
+
// Sibling tunnels on the same alias share the pinned connection: release it
|
|
119
|
+
// only once the last tunnel using it is gone. A record the pool has since
|
|
120
|
+
// replaced (e.g. after a broken-connection recycle) is ended directly.
|
|
121
|
+
const shared = [...engine.tunnels.values()].some(candidate => candidate.record === tunnel.record)
|
|
122
|
+
if (!shared) {
|
|
123
|
+
if (engine.pool.get(tunnel.alias) === tunnel.record) disposeRecord(engine, tunnel.alias, tunnel.record)
|
|
124
|
+
else endRecordChain(tunnel.record)
|
|
125
|
+
}
|
|
112
126
|
return true
|
|
113
127
|
}
|
|
114
128
|
|
package/src/engine.ts
CHANGED
|
@@ -135,6 +135,17 @@ export class SshEngine {
|
|
|
135
135
|
return stopAllTunnels(this, alias)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Drop every live artifact bound to one alias: stop its tunnels and close
|
|
140
|
+
* the pooled connection. Host entries that are deleted or whose connection
|
|
141
|
+
* fields change must never keep serving a stale, previously authenticated
|
|
142
|
+
* connection — the next operation re-connects from the current config.
|
|
143
|
+
*/
|
|
144
|
+
dropAlias(alias: string): void {
|
|
145
|
+
stopAllTunnels(this, alias)
|
|
146
|
+
disposeRecord(this, alias)
|
|
147
|
+
}
|
|
148
|
+
|
|
138
149
|
// ------------------------------------------------------------- misc
|
|
139
150
|
|
|
140
151
|
/** Probe connectivity: connect, run `true`, close. */
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { SshEngine } from './engine.ts'
|
|
|
18
18
|
import { makeRoutes } from './routes.ts'
|
|
19
19
|
import { HostStore } from './store.ts'
|
|
20
20
|
import { sshClusterTool, sshDownloadTool, sshExecTool, sshListTool, sshTunnelTool, sshUploadTool } from './tools.ts'
|
|
21
|
+
import { mountOnce } from './mount-once.ts'
|
|
21
22
|
|
|
22
23
|
/** Stable cordis plugin name. */
|
|
23
24
|
export const name = 'ssh'
|
|
@@ -62,7 +63,9 @@ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运
|
|
|
62
63
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
63
64
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
64
65
|
*/
|
|
65
|
-
export
|
|
66
|
+
export const apply = mountOnce('@linxin666/dsh-ssh', applyImpl)
|
|
67
|
+
|
|
68
|
+
function applyImpl(ctx: Context, config?: Config): void {
|
|
66
69
|
// The live source the surfaces read: the settings section once the web
|
|
67
70
|
// settings surface is served, the composition entry otherwise.
|
|
68
71
|
let current: () => Config = () => config ?? {}
|
package/src/loopback.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
4
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
5
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
6
|
+
* re-run the sync instead of editing a copy.
|
|
7
|
+
*
|
|
8
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
17
|
+
export function isIPv4Loopback(v4: string): boolean {
|
|
18
|
+
const parts = v4.split('.')
|
|
19
|
+
return parts.length === 4
|
|
20
|
+
&& parts[0] === '127'
|
|
21
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
25
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
26
|
+
if (address === undefined) return false
|
|
27
|
+
const normalized = address.toLowerCase()
|
|
28
|
+
if (normalized === '::1') return true
|
|
29
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
30
|
+
return isIPv4Loopback(normalized)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
34
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
return isIPv4Loopback(hostname)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
|
+
* header, plus browser same-origin markers. The socket address is
|
|
42
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
43
|
+
*/
|
|
44
|
+
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
46
|
+
const host = request.headers.host
|
|
47
|
+
if (typeof host !== 'string') return false
|
|
48
|
+
let hostUrl: URL
|
|
49
|
+
try {
|
|
50
|
+
hostUrl = new URL('http://' + host)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
55
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
56
|
+
const origin = request.headers.origin
|
|
57
|
+
if (origin === undefined) return true
|
|
58
|
+
try {
|
|
59
|
+
return new URL(origin).host === hostUrl.host
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
16
16
|
import { WebSocket, WebSocketServer } from 'ws'
|
|
17
17
|
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
18
18
|
import type { SshEngine, ShellSession } from './engine.ts'
|
|
19
|
+
import { isLoopbackRequest } from './loopback.ts'
|
|
19
20
|
import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
|
|
20
21
|
import type { HostStore } from './store.ts'
|
|
21
22
|
|
|
@@ -38,29 +39,6 @@ const BACKPRESSURE_HIGH_WATER = 1024 * 1024
|
|
|
38
39
|
/** …and resume once it drains below this. */
|
|
39
40
|
const BACKPRESSURE_LOW_WATER = 512 * 1024
|
|
40
41
|
|
|
41
|
-
/** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
|
|
42
|
-
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
43
|
-
const address = request.socket.remoteAddress
|
|
44
|
-
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
45
|
-
const host = request.headers.host
|
|
46
|
-
if (typeof host !== 'string') return false
|
|
47
|
-
let hostUrl: URL
|
|
48
|
-
try {
|
|
49
|
-
hostUrl = new URL(`http://${host}`)
|
|
50
|
-
} catch {
|
|
51
|
-
return false
|
|
52
|
-
}
|
|
53
|
-
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
54
|
-
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
55
|
-
const origin = request.headers.origin
|
|
56
|
-
if (origin === undefined) return true
|
|
57
|
-
try {
|
|
58
|
-
return new URL(origin).host === hostUrl.host
|
|
59
|
-
} catch {
|
|
60
|
-
return false
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
42
|
/** One JSON response. */
|
|
65
43
|
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
66
44
|
const payload = JSON.stringify(body)
|
|
@@ -100,6 +78,8 @@ export interface SshRoutesDeps {
|
|
|
100
78
|
engine: SshEngine
|
|
101
79
|
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
102
80
|
stagingDir?: string
|
|
81
|
+
/** Upload byte cap override (tests); defaults to MAX_UPLOAD_BYTES. */
|
|
82
|
+
maxUploadBytes?: number
|
|
103
83
|
}
|
|
104
84
|
|
|
105
85
|
/**
|
|
@@ -110,6 +90,7 @@ export interface SshRoutesDeps {
|
|
|
110
90
|
export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
|
|
111
91
|
const { store, engine } = deps
|
|
112
92
|
const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
|
|
93
|
+
const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
113
94
|
// The upload route stages request bodies here; it must exist before the
|
|
114
95
|
// first request (a missing dir would hang the first upload forever).
|
|
115
96
|
mkdirSync(staging, { recursive: true })
|
|
@@ -176,6 +157,13 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
176
157
|
}
|
|
177
158
|
try {
|
|
178
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
|
+
}
|
|
179
167
|
writeJson(res, 200, { host: store.summarize(entry) })
|
|
180
168
|
} catch (error) {
|
|
181
169
|
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
@@ -184,7 +172,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
184
172
|
}
|
|
185
173
|
if (method === 'DELETE') {
|
|
186
174
|
try {
|
|
187
|
-
engine.
|
|
175
|
+
engine.dropAlias(alias)
|
|
188
176
|
store.delete(alias)
|
|
189
177
|
writeJson(res, 200, { ok: true })
|
|
190
178
|
} catch (error) {
|
|
@@ -351,7 +339,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
351
339
|
return
|
|
352
340
|
}
|
|
353
341
|
const declared = Number(req.headers['content-length'])
|
|
354
|
-
if (Number.isFinite(declared) && declared >
|
|
342
|
+
if (Number.isFinite(declared) && declared > maxUploadBytes) {
|
|
355
343
|
writeJson(res, 413, { error: 'upload body too large' })
|
|
356
344
|
return
|
|
357
345
|
}
|
|
@@ -387,6 +375,17 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
387
375
|
req.on('aborted', () => fail('upload aborted by the client'))
|
|
388
376
|
res.on('error', () => fail('response stream closed'))
|
|
389
377
|
res.on('close', () => { if (!res.writableEnded) fail('connection closed') })
|
|
378
|
+
// The content-length pre-check above can be bypassed by chunked or
|
|
379
|
+
// header-less requests: count the bytes as they actually arrive and
|
|
380
|
+
// abort the moment the cap is exceeded.
|
|
381
|
+
let received = 0
|
|
382
|
+
req.on('data', (chunk: Buffer) => {
|
|
383
|
+
received += chunk.byteLength
|
|
384
|
+
if (received > maxUploadBytes) {
|
|
385
|
+
fail('upload body too large')
|
|
386
|
+
try { req.destroy() } catch { /* closed */ }
|
|
387
|
+
}
|
|
388
|
+
})
|
|
390
389
|
req.pipe(sink)
|
|
391
390
|
sink.on('finish', async () => {
|
|
392
391
|
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
|
|