dsh-ssh-manage 0.3.0 → 0.3.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.en.md +10 -9
- package/README.md +10 -9
- package/assets/LICENSE.addon-search +19 -0
- package/assets/addon-search.js +2 -0
- package/client.js +570 -27
- package/index.js +23 -2
- package/lib/ssh-client.js +87 -2
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
import { acceptWebSocket, isWebSocketUpgrade } from './lib/ws.js'
|
|
5
5
|
import { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost } from './lib/ssh-config.js'
|
|
6
6
|
import { deleteMetadata, loadMetadata, updateMetadata } from './lib/metadata.js'
|
|
7
|
-
import { batchExec, downloadFile, execCommand, inspectHost, listRemoteFiles, openPty, testConnection, uploadFile } from './lib/ssh-client.js'
|
|
7
|
+
import { batchExec, deleteRemotePath, downloadFile, execCommand, inspectHost, listRemoteFiles, makeRemoteDirectory, openPty, renameRemotePath, testConnection, uploadFile } from './lib/ssh-client.js'
|
|
8
8
|
import { storagePaths } from './lib/storage.js'
|
|
9
9
|
import { getPassword, listPasswordAliases, removePassword, setPassword } from './lib/vault.js'
|
|
10
10
|
|
|
@@ -28,9 +28,10 @@ const ASSETS = [
|
|
|
28
28
|
{ path: '/dsh-ssh-manage/assets/xterm.js', file: 'xterm.js', contentType: 'text/javascript; charset=utf-8' },
|
|
29
29
|
{ path: '/dsh-ssh-manage/assets/xterm.css', file: 'xterm.css', contentType: 'text/css; charset=utf-8' },
|
|
30
30
|
{ path: '/dsh-ssh-manage/assets/addon-fit.js', file: 'addon-fit.js', contentType: 'text/javascript; charset=utf-8' },
|
|
31
|
+
{ path: '/dsh-ssh-manage/assets/addon-search.js', file: 'addon-search.js', contentType: 'text/javascript; charset=utf-8' },
|
|
31
32
|
]
|
|
32
33
|
const MAX_BODY_BYTES = 16 * 1024 * 1024
|
|
33
|
-
const ACTIONS = ['overview', 'list_hosts', 'save_host', 'delete_host', 'set_metadata', 'save_password', 'delete_password', 'test_connection', 'inspect_host', 'exec', 'batch_exec', 'sftp_list', 'sftp_upload', 'sftp_download']
|
|
34
|
+
const ACTIONS = ['overview', 'list_hosts', 'save_host', 'delete_host', 'set_metadata', 'save_password', 'delete_password', 'test_connection', 'inspect_host', 'exec', 'batch_exec', 'sftp_list', 'sftp_upload', 'sftp_download', 'sftp_mkdir', 'sftp_rename', 'sftp_delete']
|
|
34
35
|
|
|
35
36
|
async function readJsonBody(req) {
|
|
36
37
|
const chunks = []
|
|
@@ -216,6 +217,18 @@ export async function runAction(paths, action, params = {}) {
|
|
|
216
217
|
const host = await findHost(paths, params.host)
|
|
217
218
|
return await downloadFile(host, await requirePassword(paths, host), requireString(params, 'path'))
|
|
218
219
|
}
|
|
220
|
+
case 'sftp/mkdir': {
|
|
221
|
+
const host = await findHost(paths, params.host)
|
|
222
|
+
return await makeRemoteDirectory(host, await requirePassword(paths, host), requireString(params, 'path'))
|
|
223
|
+
}
|
|
224
|
+
case 'sftp/rename': {
|
|
225
|
+
const host = await findHost(paths, params.host)
|
|
226
|
+
return await renameRemotePath(host, await requirePassword(paths, host), requireString(params, 'path'), requireString(params, 'newPath'), { overwrite: params.overwrite === true })
|
|
227
|
+
}
|
|
228
|
+
case 'sftp/delete': {
|
|
229
|
+
const host = await findHost(paths, params.host)
|
|
230
|
+
return await deleteRemotePath(host, await requirePassword(paths, host), requireString(params, 'path'))
|
|
231
|
+
}
|
|
219
232
|
default:
|
|
220
233
|
throw new Error(`Unknown action: ${String(action)}`)
|
|
221
234
|
}
|
|
@@ -240,6 +253,8 @@ function managerTool(paths) {
|
|
|
240
253
|
command: { type: 'string', description: 'Remote shell command.' },
|
|
241
254
|
hosts: { type: 'array', items: { type: 'string' }, description: 'Host aliases for a batch command.' },
|
|
242
255
|
path: { type: 'string', description: 'Remote SFTP path.' },
|
|
256
|
+
newPath: { type: 'string', description: 'New remote SFTP path for rename.' },
|
|
257
|
+
overwrite: { type: 'boolean', description: 'Allow sftp_rename to replace an existing destination (default false).' },
|
|
243
258
|
contentBase64: { type: 'string', description: 'Base64 encoded file content for upload.' },
|
|
244
259
|
metadata: { type: 'object', description: '{ group, tags, favorite, label, note, system, cpuCores, memoryGB, diskGB, latencyMs, checkedAt }' },
|
|
245
260
|
},
|
|
@@ -283,6 +298,12 @@ function managerTool(paths) {
|
|
|
283
298
|
return await runAction(paths, 'sftp/upload', params)
|
|
284
299
|
case 'sftp_download':
|
|
285
300
|
return await runAction(paths, 'sftp/download', params)
|
|
301
|
+
case 'sftp_mkdir':
|
|
302
|
+
return await runAction(paths, 'sftp/mkdir', params)
|
|
303
|
+
case 'sftp_rename':
|
|
304
|
+
return await runAction(paths, 'sftp/rename', params)
|
|
305
|
+
case 'sftp_delete':
|
|
306
|
+
return await runAction(paths, 'sftp/delete', params)
|
|
286
307
|
default:
|
|
287
308
|
throw new Error(`Unknown action: ${String(input.action)}`)
|
|
288
309
|
}
|
package/lib/ssh-client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
1
2
|
import { Client } from 'ssh2'
|
|
2
3
|
import { verifyHostKey } from './known-hosts.js'
|
|
3
4
|
|
|
@@ -105,6 +106,21 @@ function safeRemotePath(value) {
|
|
|
105
106
|
return value
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/** POSIX-normalize a remote path; used only for guards and comparisons. */
|
|
110
|
+
export function normalizeRemotePath(value) {
|
|
111
|
+
const raw = safeRemotePath(value)
|
|
112
|
+
const normalized = path.posix.normalize(raw)
|
|
113
|
+
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isProtectedRemotePath(value) {
|
|
117
|
+
const normalized = normalizeRemotePath(value)
|
|
118
|
+
if (normalized === '' || normalized === '/' || normalized === '.' || normalized === '..') return true
|
|
119
|
+
if (normalized.startsWith('../') || normalized.includes('/../') || normalized.endsWith('/..') || normalized.includes('/./')) return true
|
|
120
|
+
if (/^[A-Za-z]:(?:\.{1,2})?$/.test(normalized)) return true
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
function probeInteger(value) {
|
|
109
125
|
const numeric = Number(String(value ?? '').trim())
|
|
110
126
|
if (!Number.isFinite(numeric)) return 0
|
|
@@ -170,7 +186,13 @@ export async function listRemoteFiles(host, password, remotePath = '.') {
|
|
|
170
186
|
if (error) return reject(error)
|
|
171
187
|
sftp.readdir(target, (readError, entries) => {
|
|
172
188
|
if (readError) return reject(readError)
|
|
173
|
-
resolve(entries.map((entry) => ({
|
|
189
|
+
resolve(entries.map((entry) => ({
|
|
190
|
+
name: entry.filename,
|
|
191
|
+
size: entry.attrs?.size ?? 0,
|
|
192
|
+
mode: entry.attrs?.mode ?? 0,
|
|
193
|
+
directory: Boolean(entry.attrs?.isDirectory?.()),
|
|
194
|
+
symlink: Boolean(entry.attrs?.isSymbolicLink?.()),
|
|
195
|
+
})))
|
|
174
196
|
})
|
|
175
197
|
})
|
|
176
198
|
})
|
|
@@ -208,6 +230,69 @@ export async function downloadFile(host, password, remotePath) {
|
|
|
208
230
|
} finally { client.end() }
|
|
209
231
|
}
|
|
210
232
|
|
|
233
|
+
async function withSftp(host, password, operation) {
|
|
234
|
+
const client = await connect(host, password)
|
|
235
|
+
try {
|
|
236
|
+
const sftp = await new Promise((resolve, reject) => client.sftp((error, value) => error ? reject(error) : resolve(value)))
|
|
237
|
+
return await operation(sftp)
|
|
238
|
+
} finally {
|
|
239
|
+
client.end()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function makeRemoteDirectory(host, password, remotePath) {
|
|
244
|
+
const target = safeRemotePath(remotePath)
|
|
245
|
+
await withSftp(host, password, (sftp) => new Promise((resolve, reject) => {
|
|
246
|
+
sftp.mkdir(target, (error) => error ? reject(error) : resolve())
|
|
247
|
+
}))
|
|
248
|
+
return { created: true, path: target }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function renameRemotePath(host, password, remotePath, nextPath, options = {}) {
|
|
252
|
+
const from = safeRemotePath(remotePath)
|
|
253
|
+
const to = safeRemotePath(nextPath)
|
|
254
|
+
if (normalizeRemotePath(from) === normalizeRemotePath(to)) throw new Error('Source and destination are the same')
|
|
255
|
+
if (isProtectedRemotePath(to)) throw new Error('Refusing to rename onto a root path')
|
|
256
|
+
await withSftp(host, password, async (sftp) => {
|
|
257
|
+
if (options.overwrite !== true) {
|
|
258
|
+
const exists = await new Promise((resolve, reject) => {
|
|
259
|
+
sftp.stat(to, (error) => {
|
|
260
|
+
if (error) {
|
|
261
|
+
if (error.code === 2) resolve(false)
|
|
262
|
+
else reject(error)
|
|
263
|
+
} else resolve(true)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
if (exists) throw new Error(`Destination already exists: ${to}`)
|
|
267
|
+
}
|
|
268
|
+
await new Promise((resolve, reject) => {
|
|
269
|
+
sftp.rename(from, to, (error) => error ? reject(error) : resolve())
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
return { renamed: true, from, to }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function deleteRemotePath(host, password, remotePath) {
|
|
276
|
+
const target = safeRemotePath(remotePath)
|
|
277
|
+
if (isProtectedRemotePath(target)) {
|
|
278
|
+
throw new Error('Refusing to delete a root or empty path')
|
|
279
|
+
}
|
|
280
|
+
const result = await withSftp(host, password, async (sftp) => {
|
|
281
|
+
const stats = await new Promise((resolve, reject) => {
|
|
282
|
+
sftp.lstat(target, (error, value) => error ? reject(error) : resolve(value))
|
|
283
|
+
})
|
|
284
|
+
const symbolicLink = Boolean(stats?.isSymbolicLink?.())
|
|
285
|
+
const directory = !symbolicLink && Boolean(stats?.isDirectory?.())
|
|
286
|
+
await new Promise((resolve, reject) => {
|
|
287
|
+
const done = (error) => error ? reject(error) : resolve()
|
|
288
|
+
if (directory) sftp.rmdir(target, done)
|
|
289
|
+
else sftp.unlink(target, done)
|
|
290
|
+
})
|
|
291
|
+
return { directory, symbolicLink }
|
|
292
|
+
})
|
|
293
|
+
return { deleted: true, directory: result.directory, symbolicLink: result.symbolicLink, path: target }
|
|
294
|
+
}
|
|
295
|
+
|
|
211
296
|
export async function batchExec(hosts, passwordFor, command, options = {}) {
|
|
212
297
|
const concurrency = Math.min(Math.max(Number(options.concurrency || 4), 1), 16)
|
|
213
298
|
const results = []
|
|
@@ -270,4 +355,4 @@ export function openInteractiveShell(host, password, onReady, options = {}) {
|
|
|
270
355
|
})
|
|
271
356
|
}
|
|
272
357
|
|
|
273
|
-
export const __test = { safeRemotePath, connectionConfig, parseProbeOutput, MAX_OUTPUT_BYTES, MAX_FILE_BYTES }
|
|
358
|
+
export const __test = { safeRemotePath, normalizeRemotePath, isProtectedRemotePath, connectionConfig, parseProbeOutput, MAX_OUTPUT_BYTES, MAX_FILE_BYTES }
|