mysshx 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,447 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { WebSocketServer } from 'ws';
7
+ import { getMachine, connectMachine } from './sshManager.js';
8
+
9
+ const CHUNK = 256 * 1024;
10
+
11
+ function send(ws, obj) {
12
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
13
+ }
14
+
15
+ function isDir(stats) {
16
+ if (!stats) return false;
17
+ if (typeof stats.isDirectory === 'function') return stats.isDirectory();
18
+ return (stats.mode & 0o170000) === 0o040000;
19
+ }
20
+
21
+ function isLink(stats) {
22
+ if (!stats) return false;
23
+ if (typeof stats.isSymbolicLink === 'function') return stats.isSymbolicLink();
24
+ return (stats.mode & 0o170000) === 0o120000;
25
+ }
26
+
27
+ function sortEntries(entries) {
28
+ entries.sort((a, b) => {
29
+ if (a.type === 'dir' && b.type !== 'dir') return -1;
30
+ if (a.type !== 'dir' && b.type === 'dir') return 1;
31
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
32
+ });
33
+ }
34
+
35
+ function posixParent(p) {
36
+ if (!p || p === '/' || p === '.') return null;
37
+ const parent = path.posix.dirname(p);
38
+ return parent === p ? null : parent;
39
+ }
40
+
41
+ function posixJoin(base, name) {
42
+ return path.posix.join(base || '.', name);
43
+ }
44
+
45
+ // ---------- remote helpers ----------
46
+ const statSftp = (sftp, p) => new Promise((res, rej) => sftp.stat(p, (e, s) => (e ? rej(e) : res(s))));
47
+ const readdirSftp = (sftp, p) => new Promise((res, rej) => sftp.readdir(p, (e, l) => (e ? rej(e) : res(l))));
48
+ const mkdirSftp = (sftp, p) => new Promise((res, rej) => sftp.mkdir(p, (e) => (e ? rej(e) : res())));
49
+ const rmdirSftp = (sftp, p) => new Promise((res, rej) => sftp.rmdir(p, (e) => (e ? rej(e) : res())));
50
+ const unlinkSftp = (sftp, p) => new Promise((res, rej) => sftp.unlink(p, (e) => (e ? rej(e) : res())));
51
+ const renameSftp = (sftp, a, b) => new Promise((res, rej) => sftp.rename(a, b, (e) => (e ? rej(e) : res())));
52
+
53
+ async function mkdirRemoteP(sftp, target) {
54
+ const parts = String(target).split('/');
55
+ let cur = String(target).startsWith('/') ? '' : '.';
56
+ for (const part of parts) {
57
+ if (!part) continue;
58
+ cur = cur === '' ? `/${part}` : cur === '.' ? part : `${cur}/${part}`;
59
+ try {
60
+ await statSftp(sftp, cur);
61
+ } catch {
62
+ try {
63
+ await mkdirSftp(sftp, cur);
64
+ } catch {}
65
+ }
66
+ }
67
+ }
68
+
69
+ async function removeRemote(sftp, p) {
70
+ const stats = await statSftp(sftp, p);
71
+ if (isDir(stats)) {
72
+ const list = await readdirSftp(sftp, p);
73
+ for (const item of list) {
74
+ if (item.filename === '.' || item.filename === '..') continue;
75
+ await removeRemote(sftp, posixJoin(p, item.filename));
76
+ }
77
+ await rmdirSftp(sftp, p);
78
+ } else {
79
+ await unlinkSftp(sftp, p);
80
+ }
81
+ }
82
+
83
+ // ---------- listings ----------
84
+ async function listLocal(p) {
85
+ const abs = path.resolve(p || os.homedir());
86
+ const dirents = await fsp.readdir(abs, { withFileTypes: true });
87
+ const entries = await Promise.all(
88
+ dirents.map(async (d) => {
89
+ const full = path.join(abs, d.name);
90
+ let stats = null;
91
+ try {
92
+ stats = await fsp.stat(full);
93
+ } catch {
94
+ try {
95
+ stats = await fsp.lstat(full);
96
+ } catch {}
97
+ }
98
+ let type = 'file';
99
+ if (d.isDirectory()) type = 'dir';
100
+ else if (d.isSymbolicLink() && stats && stats.isDirectory()) type = 'dir';
101
+ else if (d.isSymbolicLink()) type = 'link';
102
+ return { name: d.name, type, size: stats ? stats.size : 0, mtime: stats ? stats.mtimeMs : 0, path: full };
103
+ })
104
+ );
105
+ sortEntries(entries);
106
+ const parent = path.dirname(abs);
107
+ return { path: abs, parent: parent === abs ? null : parent, entries };
108
+ }
109
+
110
+ function listRemote(sftp, p) {
111
+ return new Promise((resolve, reject) => {
112
+ const target = p || '.';
113
+ sftp.readdir(target, (err, list) => {
114
+ if (err) return reject(err);
115
+ const entries = [];
116
+ for (const item of list) {
117
+ if (item.filename === '.' || item.filename === '..') continue;
118
+ const attrs = item.attrs || {};
119
+ const dir = isDir(attrs);
120
+ const link = !dir && isLink(attrs);
121
+ entries.push({
122
+ name: item.filename,
123
+ type: dir ? 'dir' : link ? 'link' : 'file',
124
+ size: attrs.size || 0,
125
+ mtime: (attrs.mtime || 0) * 1000,
126
+ path: path.posix.join(target, item.filename),
127
+ });
128
+ }
129
+ sortEntries(entries);
130
+ resolve({ path: target, parent: posixParent(target), entries });
131
+ });
132
+ });
133
+ }
134
+
135
+ // ---------- sizes (for progress totals) ----------
136
+ async function sumSizeLocal(p) {
137
+ const stats = await fsp.stat(p);
138
+ if (stats.isDirectory()) {
139
+ let total = 0;
140
+ const names = await fsp.readdir(p);
141
+ for (const n of names) {
142
+ try {
143
+ total += await sumSizeLocal(path.join(p, n));
144
+ } catch {}
145
+ }
146
+ return total;
147
+ }
148
+ return stats.size;
149
+ }
150
+
151
+ async function sumSizeRemote(sftp, p) {
152
+ const stats = await statSftp(sftp, p);
153
+ if (isDir(stats)) {
154
+ let total = 0;
155
+ const list = await readdirSftp(sftp, p);
156
+ for (const item of list) {
157
+ if (item.filename === '.' || item.filename === '..') continue;
158
+ try {
159
+ total += await sumSizeRemote(sftp, posixJoin(p, item.filename));
160
+ } catch {}
161
+ }
162
+ return total;
163
+ }
164
+ return stats.size || 0;
165
+ }
166
+
167
+ // ---------- transfers ----------
168
+ function publicTransfer(t) {
169
+ return {
170
+ id: t.id,
171
+ name: t.name,
172
+ direction: t.direction,
173
+ status: t.status,
174
+ transferred: t.transferred,
175
+ total: t.total,
176
+ error: t.error || null,
177
+ };
178
+ }
179
+
180
+ function emitProgress(ws, t, force = false) {
181
+ const now = Date.now();
182
+ if (force || now - t._last > 150) {
183
+ t._last = now;
184
+ send(ws, { event: 'progress', transfer: publicTransfer(t) });
185
+ }
186
+ }
187
+
188
+ function startTransfer(ws, transfers, opts) {
189
+ const t = {
190
+ id: crypto.randomUUID(),
191
+ name: opts.name,
192
+ direction: opts.direction,
193
+ status: 'pending',
194
+ transferred: 0,
195
+ total: 0,
196
+ error: null,
197
+ aborted: false,
198
+ streams: [],
199
+ _last: 0,
200
+ };
201
+ transfers.set(t.id, t);
202
+ send(ws, { event: 'transfer-add', transfer: publicTransfer(t) });
203
+
204
+ (async () => {
205
+ try {
206
+ t.total = await opts.total();
207
+ t.status = 'active';
208
+ emitProgress(ws, t, true);
209
+ await opts.run(t);
210
+ t.status = t.aborted ? 'cancelled' : 'done';
211
+ } catch (err) {
212
+ if (t.aborted) t.status = 'cancelled';
213
+ else {
214
+ t.status = 'error';
215
+ t.error = err.message;
216
+ }
217
+ }
218
+ emitProgress(ws, t, true);
219
+ send(ws, { event: 'refresh', side: opts.refreshSide, path: opts.refreshPath });
220
+ setTimeout(() => {
221
+ transfers.delete(t.id);
222
+ send(ws, { event: 'transfer-remove', id: t.id });
223
+ }, 6000);
224
+ })();
225
+ }
226
+
227
+ function uploadFile(ws, sftp, t, localPath, remotePath) {
228
+ return new Promise((resolve, reject) => {
229
+ const rs = fs.createReadStream(localPath, { highWaterMark: CHUNK });
230
+ const out = sftp.createWriteStream(remotePath);
231
+ t.streams.push(rs, out);
232
+ rs.on('error', reject);
233
+ out.on('error', reject);
234
+ rs.on('data', (chunk) => {
235
+ t.transferred += chunk.length;
236
+ emitProgress(ws, t);
237
+ });
238
+ out.on('close', resolve);
239
+ rs.pipe(out);
240
+ });
241
+ }
242
+
243
+ async function uploadPath(ws, sftp, t, localPath, remotePath) {
244
+ const stats = await fsp.stat(localPath);
245
+ if (stats.isDirectory()) {
246
+ await mkdirRemoteP(sftp, remotePath);
247
+ const names = await fsp.readdir(localPath);
248
+ for (const n of names) {
249
+ if (t.aborted) return;
250
+ await uploadPath(ws, sftp, t, path.join(localPath, n), posixJoin(remotePath, n));
251
+ }
252
+ } else {
253
+ await uploadFile(ws, sftp, t, localPath, remotePath);
254
+ }
255
+ }
256
+
257
+ function downloadFile(ws, sftp, t, remotePath, localPath) {
258
+ return new Promise((resolve, reject) => {
259
+ const rs = sftp.createReadStream(remotePath);
260
+ const out = fs.createWriteStream(localPath);
261
+ t.streams.push(rs, out);
262
+ rs.on('error', reject);
263
+ out.on('error', reject);
264
+ rs.on('data', (chunk) => {
265
+ t.transferred += chunk.length;
266
+ emitProgress(ws, t);
267
+ });
268
+ out.on('close', resolve);
269
+ rs.pipe(out);
270
+ });
271
+ }
272
+
273
+ function downloadPath(ws, sftp, t, remotePath, localPath) {
274
+ return new Promise((resolve, reject) => {
275
+ sftp.stat(remotePath, async (err, stats) => {
276
+ if (err) return reject(err);
277
+ try {
278
+ if (isDir(stats)) {
279
+ await fsp.mkdir(localPath, { recursive: true });
280
+ const list = await readdirSftp(sftp, remotePath);
281
+ for (const item of list) {
282
+ if (item.filename === '.' || item.filename === '..') continue;
283
+ if (t.aborted) return resolve();
284
+ await downloadPath(ws, sftp, t, posixJoin(remotePath, item.filename), path.join(localPath, item.filename));
285
+ }
286
+ resolve();
287
+ } else {
288
+ await downloadFile(ws, sftp, t, remotePath, localPath);
289
+ resolve();
290
+ }
291
+ } catch (e) {
292
+ reject(e);
293
+ }
294
+ });
295
+ });
296
+ }
297
+
298
+ // ---------- websocket ----------
299
+ export function attachSftp(server) {
300
+ const wss = new WebSocketServer({ noServer: true });
301
+
302
+ server.on('upgrade', (req, socket, head) => {
303
+ const url = new URL(req.url, 'http://localhost');
304
+ const match = url.pathname.match(/^\/ws\/sftp\/([^/]+)$/);
305
+ if (!match) return;
306
+ wss.handleUpgrade(req, socket, head, (ws) => handle(ws, decodeURIComponent(match[1])));
307
+ });
308
+ }
309
+
310
+ async function handle(ws, machineId) {
311
+ const machine = getMachine(machineId);
312
+ if (!machine) {
313
+ send(ws, { event: 'fatal', message: '机器不存在' });
314
+ return ws.close();
315
+ }
316
+
317
+ let client;
318
+ let sftp;
319
+ try {
320
+ client = await connectMachine(machine);
321
+ sftp = await new Promise((resolve, reject) => client.sftp((e, s) => (e ? reject(e) : resolve(s))));
322
+ } catch (err) {
323
+ send(ws, { event: 'fatal', message: `连接失败: ${err.message}` });
324
+ try {
325
+ client && client.end();
326
+ } catch {}
327
+ return ws.close();
328
+ }
329
+
330
+ let remoteHome = '.';
331
+ try {
332
+ remoteHome = await new Promise((resolve, reject) => sftp.realpath('.', (e, p) => (e ? reject(e) : resolve(p))));
333
+ } catch {}
334
+
335
+ const transfers = new Map();
336
+ send(ws, { event: 'ready', name: machine.name, localHome: os.homedir(), remoteHome });
337
+
338
+ ws.on('message', (raw) => {
339
+ let msg;
340
+ try {
341
+ msg = JSON.parse(raw.toString());
342
+ } catch {
343
+ return;
344
+ }
345
+ dispatch(msg).catch((err) => {
346
+ send(ws, { event: 'error', message: err.message, action: msg.action, side: msg.side, path: msg.path });
347
+ });
348
+ });
349
+
350
+ ws.on('close', () => {
351
+ for (const t of transfers.values()) {
352
+ t.aborted = true;
353
+ for (const s of t.streams) {
354
+ try {
355
+ s.destroy();
356
+ } catch {}
357
+ }
358
+ }
359
+ try {
360
+ client.end();
361
+ } catch {}
362
+ });
363
+
364
+ async function dispatch(msg) {
365
+ switch (msg.action) {
366
+ case 'list': {
367
+ const pane = msg.side === 'local' ? await listLocal(msg.path) : await listRemote(sftp, msg.path);
368
+ send(ws, { event: 'list', side: msg.side, ...pane });
369
+ break;
370
+ }
371
+ case 'mkdir': {
372
+ if (msg.side === 'local') {
373
+ await fsp.mkdir(path.join(msg.parent, msg.name));
374
+ } else {
375
+ await mkdirSftp(sftp, posixJoin(msg.parent, msg.name));
376
+ }
377
+ await relist(msg.side, msg.parent);
378
+ break;
379
+ }
380
+ case 'rename': {
381
+ if (msg.side === 'local') {
382
+ await fsp.rename(msg.path, path.join(path.dirname(msg.path), msg.newName));
383
+ } else {
384
+ await renameSftp(sftp, msg.path, posixJoin(posixParent(msg.path) || '.', msg.newName));
385
+ }
386
+ await relist(msg.side, msg.side === 'local' ? path.dirname(msg.path) : posixParent(msg.path));
387
+ break;
388
+ }
389
+ case 'delete': {
390
+ for (const p of msg.paths || []) {
391
+ if (msg.side === 'local') await fsp.rm(p, { recursive: true, force: true });
392
+ else await removeRemote(sftp, p);
393
+ }
394
+ await relist(msg.side, msg.parent);
395
+ break;
396
+ }
397
+ case 'upload': {
398
+ for (const localPath of msg.paths || []) {
399
+ const remotePath = posixJoin(msg.remoteDir, path.basename(localPath));
400
+ startTransfer(ws, transfers, {
401
+ name: path.basename(localPath),
402
+ direction: 'upload',
403
+ refreshSide: 'remote',
404
+ refreshPath: msg.remoteDir,
405
+ total: () => sumSizeLocal(localPath),
406
+ run: (t) => uploadPath(ws, sftp, t, localPath, remotePath),
407
+ });
408
+ }
409
+ break;
410
+ }
411
+ case 'download': {
412
+ for (const remotePath of msg.paths || []) {
413
+ const localPath = path.join(msg.localDir, path.posix.basename(remotePath));
414
+ startTransfer(ws, transfers, {
415
+ name: path.posix.basename(remotePath),
416
+ direction: 'download',
417
+ refreshSide: 'local',
418
+ refreshPath: msg.localDir,
419
+ total: () => sumSizeRemote(sftp, remotePath),
420
+ run: (t) => downloadPath(ws, sftp, t, remotePath, localPath),
421
+ });
422
+ }
423
+ break;
424
+ }
425
+ case 'cancel': {
426
+ const t = transfers.get(msg.id);
427
+ if (t) {
428
+ t.aborted = true;
429
+ for (const s of t.streams) {
430
+ try {
431
+ s.destroy();
432
+ } catch {}
433
+ }
434
+ }
435
+ break;
436
+ }
437
+ default:
438
+ throw new Error(`未知操作: ${msg.action}`);
439
+ }
440
+ }
441
+
442
+ async function relist(side, p) {
443
+ const target = side === 'local' ? p || os.homedir() : p || remoteHome;
444
+ const pane = side === 'local' ? await listLocal(target) : await listRemote(sftp, target);
445
+ send(ws, { event: 'list', side, ...pane });
446
+ }
447
+ }
@@ -0,0 +1,61 @@
1
+ import { Client } from 'ssh2';
2
+ import db from './db.js';
3
+ import { decrypt } from './crypto.js';
4
+
5
+ export function getMachine(id) {
6
+ return db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
7
+ }
8
+
9
+ function buildConfig(machine) {
10
+ const config = {
11
+ host: machine.host,
12
+ port: machine.port || 22,
13
+ username: machine.username,
14
+ readyTimeout: 20000,
15
+ keepaliveInterval: 15000,
16
+ tryKeyboard: true,
17
+ };
18
+
19
+ const privateKey = decrypt(machine.private_key);
20
+ const passphrase = decrypt(machine.passphrase);
21
+ const password = decrypt(machine.password);
22
+
23
+ if (privateKey) {
24
+ config.privateKey = privateKey;
25
+ if (passphrase) config.passphrase = passphrase;
26
+ }
27
+ if (password) config.password = password;
28
+
29
+ return { config, password };
30
+ }
31
+
32
+ export function connectMachine(machine) {
33
+ return new Promise((resolve, reject) => {
34
+ const client = new Client();
35
+ const { config, password } = buildConfig(machine);
36
+ let settled = false;
37
+ const done = (err, value) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ err ? reject(err) : resolve(value);
41
+ };
42
+
43
+ client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
44
+ finish(prompts.map(() => password || ''));
45
+ });
46
+ client.on('ready', () => done(null, client));
47
+ client.on('error', (err) => done(err));
48
+
49
+ try {
50
+ client.connect(config);
51
+ } catch (err) {
52
+ done(err);
53
+ }
54
+ });
55
+ }
56
+
57
+ export async function testConnection(machine) {
58
+ const client = await connectMachine(machine);
59
+ client.end();
60
+ return true;
61
+ }
@@ -0,0 +1,94 @@
1
+ import { WebSocketServer } from 'ws';
2
+ import { getMachine, connectMachine } from './sshManager.js';
3
+
4
+ function send(ws, obj) {
5
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
6
+ }
7
+
8
+ export function attachTerminal(server) {
9
+ const wss = new WebSocketServer({ noServer: true });
10
+
11
+ server.on('upgrade', (req, socket, head) => {
12
+ const url = new URL(req.url, 'http://localhost');
13
+ const match = url.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
14
+ if (!match) return;
15
+ wss.handleUpgrade(req, socket, head, (ws) => handle(ws, decodeURIComponent(match[1])));
16
+ });
17
+ }
18
+
19
+ async function handle(ws, machineId) {
20
+ const machine = getMachine(machineId);
21
+ if (!machine) {
22
+ send(ws, { type: 'error', message: '机器不存在' });
23
+ return ws.close();
24
+ }
25
+
26
+ let stream = null;
27
+ const pending = [];
28
+
29
+ const applyMessage = (msg) => {
30
+ if (!stream) return;
31
+ if (msg.type === 'input' && msg.data) {
32
+ stream.write(Buffer.from(msg.data, 'base64'));
33
+ } else if (msg.type === 'resize' && stream.setWindow) {
34
+ const cols = Math.max(1, msg.cols | 0);
35
+ const rows = Math.max(1, msg.rows | 0);
36
+ stream.setWindow(rows, cols, 0, 0);
37
+ }
38
+ };
39
+
40
+ ws.on('message', (raw) => {
41
+ let msg;
42
+ try {
43
+ msg = JSON.parse(raw.toString());
44
+ } catch {
45
+ return;
46
+ }
47
+ if (stream) applyMessage(msg);
48
+ else pending.push(msg);
49
+ });
50
+
51
+ let client;
52
+ try {
53
+ client = await connectMachine(machine);
54
+ } catch (err) {
55
+ send(ws, { type: 'error', message: `连接失败: ${err.message}` });
56
+ return ws.close();
57
+ }
58
+
59
+ client.shell(
60
+ { term: 'xterm-256color', cols: 80, rows: 24 },
61
+ (err, s) => {
62
+ if (err) {
63
+ send(ws, { type: 'error', message: `打开终端失败: ${err.message}` });
64
+ try {
65
+ client.end();
66
+ } catch {}
67
+ return ws.close();
68
+ }
69
+
70
+ stream = s;
71
+ for (const msg of pending) applyMessage(msg);
72
+ pending.length = 0;
73
+
74
+ send(ws, { type: 'status', status: 'connected', name: machine.name });
75
+
76
+ const push = (data) => send(ws, { type: 'data', data: data.toString('base64') });
77
+ stream.on('data', push);
78
+ if (stream.stderr) stream.stderr.on('data', push);
79
+ stream.on('close', (code) => {
80
+ send(ws, { type: 'status', status: 'closed', code });
81
+ try {
82
+ client.end();
83
+ } catch {}
84
+ ws.close();
85
+ });
86
+ }
87
+ );
88
+
89
+ ws.on('close', () => {
90
+ try {
91
+ client.end();
92
+ } catch {}
93
+ });
94
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.