mysshx 0.0.0-stage → 1.0.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/bin/mysshx.js +86 -0
- package/client/dist/assets/index-DYP7pi_n.css +32 -0
- package/client/dist/assets/index-bA2L5FBW.js +293 -0
- package/client/dist/index.html +13 -0
- package/package.json +37 -4
- package/server/crypto.js +42 -0
- package/server/db.js +33 -0
- package/server/index.js +61 -0
- package/server/machines.js +127 -0
- package/server/package.json +11 -0
- package/server/sftpSession.js +447 -0
- package/server/sshManager.js +61 -0
- package/server/terminalSession.js +81 -0
- package/README.md +0 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>mysshx - SSH 管理器</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-bA2L5FBW.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DYP7pi_n.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mysshx",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SSH/SFTP 管理器:Node.js 后端 + MUI 前端 + SQLite 存储,可通过 npx 运行",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.5.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"mysshx": "bin/mysshx.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"server",
|
|
15
|
+
"!server/data",
|
|
16
|
+
"client/dist"
|
|
17
|
+
],
|
|
18
|
+
"workspaces": [
|
|
19
|
+
"server",
|
|
20
|
+
"client"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "concurrently -n server,client -c blue,green \"npm:dev:server\" \"npm:dev:client\"",
|
|
24
|
+
"dev:server": "npm run dev --workspace=server",
|
|
25
|
+
"dev:client": "npm run dev --workspace=client",
|
|
26
|
+
"build": "npm run build --workspace=client",
|
|
27
|
+
"prepack": "npm run build",
|
|
28
|
+
"start": "node bin/mysshx.js"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"cors": "^2.8.5",
|
|
32
|
+
"express": "^4.21.2",
|
|
33
|
+
"ssh2": "^1.16.0",
|
|
34
|
+
"ws": "^8.18.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"concurrently": "^9.1.2"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/server/crypto.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { dataDir } from './db.js';
|
|
5
|
+
|
|
6
|
+
const keyPath = path.join(dataDir, 'secret.key');
|
|
7
|
+
const PREFIX = 'v1';
|
|
8
|
+
|
|
9
|
+
function getKey() {
|
|
10
|
+
if (!fs.existsSync(dataDir)) {
|
|
11
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
if (!fs.existsSync(keyPath)) {
|
|
14
|
+
fs.writeFileSync(keyPath, crypto.randomBytes(32), { mode: 0o600 });
|
|
15
|
+
}
|
|
16
|
+
return fs.readFileSync(keyPath);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function encrypt(plain) {
|
|
20
|
+
if (plain === undefined || plain === null || plain === '') return null;
|
|
21
|
+
const iv = crypto.randomBytes(12);
|
|
22
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
|
|
23
|
+
const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
24
|
+
const tag = cipher.getAuthTag();
|
|
25
|
+
return [PREFIX, iv.toString('base64'), tag.toString('base64'), enc.toString('base64')].join(':');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decrypt(payload) {
|
|
29
|
+
if (!payload) return null;
|
|
30
|
+
const parts = String(payload).split(':');
|
|
31
|
+
if (parts.length !== 4 || parts[0] !== PREFIX) return null;
|
|
32
|
+
try {
|
|
33
|
+
const iv = Buffer.from(parts[1], 'base64');
|
|
34
|
+
const tag = Buffer.from(parts[2], 'base64');
|
|
35
|
+
const data = Buffer.from(parts[3], 'base64');
|
|
36
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
|
|
37
|
+
decipher.setAuthTag(tag);
|
|
38
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/server/db.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const dataDir = process.env.MYSSHX_DATA_DIR
|
|
7
|
+
? path.resolve(process.env.MYSSHX_DATA_DIR)
|
|
8
|
+
: process.env.MYSSH_DATA_DIR
|
|
9
|
+
? path.resolve(process.env.MYSSH_DATA_DIR)
|
|
10
|
+
: path.join(os.homedir(), '.mysshx');
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(dataDir)) {
|
|
13
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const db = new DatabaseSync(path.join(dataDir, 'myssh.db'));
|
|
17
|
+
|
|
18
|
+
db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS machines (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT NOT NULL,
|
|
22
|
+
host TEXT NOT NULL,
|
|
23
|
+
port INTEGER NOT NULL DEFAULT 22,
|
|
24
|
+
username TEXT NOT NULL,
|
|
25
|
+
password TEXT,
|
|
26
|
+
private_key TEXT,
|
|
27
|
+
passphrase TEXT,
|
|
28
|
+
created_at TEXT NOT NULL,
|
|
29
|
+
updated_at TEXT NOT NULL
|
|
30
|
+
);
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
export default db;
|
package/server/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import express from 'express';
|
|
6
|
+
import cors from 'cors';
|
|
7
|
+
import machinesRouter from './machines.js';
|
|
8
|
+
import { attachTerminal } from './terminalSession.js';
|
|
9
|
+
import { attachSftp } from './sftpSession.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const clientDist = path.join(__dirname, '..', 'client', 'dist');
|
|
13
|
+
|
|
14
|
+
export function createApp() {
|
|
15
|
+
const app = express();
|
|
16
|
+
app.use(cors());
|
|
17
|
+
app.use(express.json({ limit: '4mb' }));
|
|
18
|
+
|
|
19
|
+
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
|
20
|
+
app.use('/api/machines', machinesRouter);
|
|
21
|
+
app.use('/api', (req, res) => res.status(404).json({ error: '接口不存在' }));
|
|
22
|
+
|
|
23
|
+
if (fs.existsSync(clientDist)) {
|
|
24
|
+
app.use(express.static(clientDist));
|
|
25
|
+
app.get('*', (req, res) => res.sendFile(path.join(clientDist, 'index.html')));
|
|
26
|
+
} else {
|
|
27
|
+
app.get('/', (req, res) =>
|
|
28
|
+
res
|
|
29
|
+
.status(503)
|
|
30
|
+
.send('前端尚未构建,请先运行: npm run build(或 npx 安装时携带构建产物)')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return app;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function startServer({ port = Number(process.env.PORT) || 3011, host = process.env.HOST || '0.0.0.0' } = {}) {
|
|
38
|
+
const app = createApp();
|
|
39
|
+
const server = http.createServer(app);
|
|
40
|
+
attachTerminal(server);
|
|
41
|
+
attachSftp(server);
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
server.once('error', reject);
|
|
45
|
+
server.listen(port, host, () => {
|
|
46
|
+
const actualPort = server.address().port;
|
|
47
|
+
console.log(`mysshx 服务已启动: http://localhost:${actualPort}`);
|
|
48
|
+
resolve({ server, port: actualPort, app });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isMain =
|
|
54
|
+
process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
55
|
+
|
|
56
|
+
if (isMain) {
|
|
57
|
+
startServer().catch((err) => {
|
|
58
|
+
console.error('启动失败:', err.message);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import db from './db.js';
|
|
4
|
+
import { encrypt } from './crypto.js';
|
|
5
|
+
import { getMachine, testConnection } from './sshManager.js';
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
function toPublic(row) {
|
|
10
|
+
if (!row) return null;
|
|
11
|
+
const { password, private_key, passphrase, ...rest } = row;
|
|
12
|
+
return {
|
|
13
|
+
...rest,
|
|
14
|
+
hasPassword: Boolean(password),
|
|
15
|
+
hasPrivateKey: Boolean(private_key),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalize(body) {
|
|
20
|
+
const name = (body.name || '').trim();
|
|
21
|
+
const host = (body.host || '').trim();
|
|
22
|
+
const username = (body.username || '').trim();
|
|
23
|
+
const port = Number.parseInt(body.port, 10) || 22;
|
|
24
|
+
if (!name) throw new HttpError(400, '名称不能为空');
|
|
25
|
+
if (!host) throw new HttpError(400, '主机地址不能为空');
|
|
26
|
+
if (!username) throw new HttpError(400, '用户名不能为空');
|
|
27
|
+
if (port < 1 || port > 65535) throw new HttpError(400, '端口无效');
|
|
28
|
+
return { name, host, username, port };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class HttpError extends Error {
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.status = status;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
router.get('/', (req, res) => {
|
|
39
|
+
const rows = db
|
|
40
|
+
.prepare('SELECT * FROM machines ORDER BY name COLLATE NOCASE ASC')
|
|
41
|
+
.all();
|
|
42
|
+
res.json(rows.map(toPublic));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
router.get('/:id', (req, res) => {
|
|
46
|
+
const row = getMachine(req.params.id);
|
|
47
|
+
if (!row) return res.status(404).json({ error: '机器不存在' });
|
|
48
|
+
res.json(toPublic(row));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
router.post('/', (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const { name, host, username, port } = normalize(req.body);
|
|
54
|
+
const id = crypto.randomUUID();
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
db.prepare(
|
|
57
|
+
`INSERT INTO machines
|
|
58
|
+
(id, name, host, port, username, password, private_key, passphrase, created_at, updated_at)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
60
|
+
).run(
|
|
61
|
+
id,
|
|
62
|
+
name,
|
|
63
|
+
host,
|
|
64
|
+
port,
|
|
65
|
+
username,
|
|
66
|
+
encrypt(req.body.password),
|
|
67
|
+
encrypt(req.body.privateKey),
|
|
68
|
+
encrypt(req.body.passphrase),
|
|
69
|
+
now,
|
|
70
|
+
now
|
|
71
|
+
);
|
|
72
|
+
res.status(201).json(toPublic(getMachine(id)));
|
|
73
|
+
} catch (err) {
|
|
74
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
router.put('/:id', (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const existing = getMachine(req.params.id);
|
|
81
|
+
if (!existing) return res.status(404).json({ error: '机器不存在' });
|
|
82
|
+
const { name, host, username, port } = normalize(req.body);
|
|
83
|
+
|
|
84
|
+
const password = req.body.password ? encrypt(req.body.password) : existing.password;
|
|
85
|
+
const privateKey = req.body.privateKey ? encrypt(req.body.privateKey) : existing.private_key;
|
|
86
|
+
const passphrase = req.body.passphrase ? encrypt(req.body.passphrase) : existing.passphrase;
|
|
87
|
+
|
|
88
|
+
db.prepare(
|
|
89
|
+
`UPDATE machines
|
|
90
|
+
SET name = ?, host = ?, port = ?, username = ?, password = ?,
|
|
91
|
+
private_key = ?, passphrase = ?, updated_at = ?
|
|
92
|
+
WHERE id = ?`
|
|
93
|
+
).run(
|
|
94
|
+
name,
|
|
95
|
+
host,
|
|
96
|
+
port,
|
|
97
|
+
username,
|
|
98
|
+
password,
|
|
99
|
+
privateKey,
|
|
100
|
+
passphrase,
|
|
101
|
+
new Date().toISOString(),
|
|
102
|
+
req.params.id
|
|
103
|
+
);
|
|
104
|
+
res.json(toPublic(getMachine(req.params.id)));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
router.delete('/:id', (req, res) => {
|
|
111
|
+
const result = db.prepare('DELETE FROM machines WHERE id = ?').run(req.params.id);
|
|
112
|
+
if (result.changes === 0) return res.status(404).json({ error: '机器不存在' });
|
|
113
|
+
res.json({ ok: true });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
router.post('/:id/test', async (req, res) => {
|
|
117
|
+
const row = getMachine(req.params.id);
|
|
118
|
+
if (!row) return res.status(404).json({ error: '机器不存在' });
|
|
119
|
+
try {
|
|
120
|
+
await testConnection(row);
|
|
121
|
+
res.json({ ok: true });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
res.status(400).json({ error: err.message });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export default router;
|