dsh-db-tool 1.5.91 → 1.7.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/client/client.js +980 -31
- package/dist/adapters/dmdb/index.js +25 -4
- package/dist/adapters/gaussdb/index.js +2 -0
- package/dist/adapters/mongodb/index.js +47 -8
- package/dist/adapters/mysql/index.js +31 -5
- package/dist/adapters/oracle/index.js +39 -9
- package/dist/adapters/postgresql/index.js +4 -0
- package/dist/adapters/sql-shared/pg-like.js +54 -16
- package/dist/adapters/sqlite/index.js +50 -19
- package/dist/adapters/transfer/kinds/mongo.js +378 -0
- package/dist/adapters/transfer/kinds/mysql.js +331 -0
- package/dist/adapters/transfer/kinds/oralike.js +437 -0
- package/dist/adapters/transfer/kinds/pg.js +311 -0
- package/dist/adapters/transfer/kinds/sqlite.js +250 -0
- package/dist/adapters/transfer/model.js +77 -0
- package/dist/http/index.js +74 -0
- package/dist/manager.js +239 -2
- package/dist/store/connections.js +85 -2
- package/dist/store/export-crypto.js +78 -0
- package/dist/store/index.js +7 -1
- package/dist/store/transfer-history.js +114 -0
- package/dist/transfer.js +287 -0
- package/package.json +1 -1
package/dist/http/index.js
CHANGED
|
@@ -148,6 +148,27 @@ async function route(req, res, service, path, q, opts) {
|
|
|
148
148
|
}));
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
+
// 连接导出/导入(口令加密包)。必须置于 connMatch 之前:
|
|
152
|
+
// /api/connections/export 与 /api/connections/import 都是单层路径,
|
|
153
|
+
// 若落到 connMatch 会被当作连接 id 匹配(DELETE 无分支静默 404、PUT 误更新)。
|
|
154
|
+
if (path === '/api/connections/export' && req.method === 'POST') {
|
|
155
|
+
const b = await readBody(req);
|
|
156
|
+
const passphrase = str(b['passphrase']);
|
|
157
|
+
if (!passphrase)
|
|
158
|
+
return send(res, 400, { ok: false, error: '缺少 passphrase', code: 'INVALID_ARGUMENT' });
|
|
159
|
+
const count = service.listConnections().length; // 权威条数,前端不再用可能过期的本地列表
|
|
160
|
+
return sendOk(res, { json: service.exportConnections(passphrase), count });
|
|
161
|
+
}
|
|
162
|
+
if (path === '/api/connections/import' && req.method === 'POST') {
|
|
163
|
+
const b = await readBody(req);
|
|
164
|
+
const json = str(b['json']);
|
|
165
|
+
const passphrase = str(b['passphrase']);
|
|
166
|
+
if (!json)
|
|
167
|
+
return send(res, 400, { ok: false, error: '缺少 json', code: 'INVALID_ARGUMENT' });
|
|
168
|
+
if (!passphrase)
|
|
169
|
+
return send(res, 400, { ok: false, error: '缺少 passphrase', code: 'INVALID_ARGUMENT' });
|
|
170
|
+
return sendOk(res, service.importConnections(json, passphrase));
|
|
171
|
+
}
|
|
151
172
|
// 测试未保存的连接草稿(保存前测试;不落库)
|
|
152
173
|
if (path === '/api/test-draft' && req.method === 'POST') {
|
|
153
174
|
const b = await readBody(req);
|
|
@@ -241,6 +262,54 @@ async function route(req, res, service, path, q, opts) {
|
|
|
241
262
|
const b = await readBody(req);
|
|
242
263
|
return await sendMaybeConfirm(res, service.runScript(projectOfBody(b), str(b['connId']), str(b['code']), optStr(b['challengeId'])));
|
|
243
264
|
}
|
|
265
|
+
// 数据传输(并行任务):start 需危险确认(challenge 往返);GET :taskId 轮询进度
|
|
266
|
+
//(快照 JSON-safe,bigint 键已转字符串);POST :taskId/cancel 取消
|
|
267
|
+
if (path === '/api/transfer/start' && req.method === 'POST') {
|
|
268
|
+
const b = await readBody(req);
|
|
269
|
+
return await sendMaybeConfirm(res, service.transferStart(projectOfBody(b), {
|
|
270
|
+
sourceConnId: str(b['sourceConnId']),
|
|
271
|
+
targetConnId: str(b['targetConnId']),
|
|
272
|
+
tables: Array.isArray(b['tables']) ? b['tables'].map(String) : [],
|
|
273
|
+
writeMode: b['writeMode'],
|
|
274
|
+
batchSize: num(b['batchSize']),
|
|
275
|
+
tableConcurrency: num(b['tableConcurrency']),
|
|
276
|
+
shardConcurrency: num(b['shardConcurrency']),
|
|
277
|
+
overwriteStructure: b['overwriteStructure'] === true,
|
|
278
|
+
sourceDatabase: optStr(b['sourceDatabase']),
|
|
279
|
+
sourceSchema: optStr(b['sourceSchema']),
|
|
280
|
+
targetDatabase: optStr(b['targetDatabase']),
|
|
281
|
+
targetSchema: optStr(b['targetSchema']),
|
|
282
|
+
}, optStr(b['challengeId'])));
|
|
283
|
+
}
|
|
284
|
+
// 传输历史:终态持久化(任何时候可查已传输/失败结果),支持按项目清除防积累
|
|
285
|
+
// 传输日志:全事件行(start/表完成/进度/终态),日志标签页 5s 轮询实时查看
|
|
286
|
+
if (path === '/api/transfer-log' && req.method === 'GET') {
|
|
287
|
+
const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
|
|
288
|
+
return sendOk(res, service.transferLogTail(projectOf(q), limit));
|
|
289
|
+
}
|
|
290
|
+
// 实时进度:运行中任务快照(含百分比所需行数),前端 2s 轮询
|
|
291
|
+
if (path === '/api/transfer/live' && req.method === 'GET') {
|
|
292
|
+
return sendOk(res, service.transferLive(projectOf(q)));
|
|
293
|
+
}
|
|
294
|
+
if (path === '/api/transfer-history' && req.method === 'GET') {
|
|
295
|
+
const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
|
|
296
|
+
return sendOk(res, service.transferHistory(projectOf(q), limit));
|
|
297
|
+
}
|
|
298
|
+
if (path === '/api/transfer-history/clear' && req.method === 'POST') {
|
|
299
|
+
return sendOk(res, service.transferHistoryClear());
|
|
300
|
+
}
|
|
301
|
+
const transferMatch = /^\/api\/transfer\/([^/]+)(\/cancel)?$/.exec(path);
|
|
302
|
+
if (transferMatch) {
|
|
303
|
+
const id = decodeURIComponent(transferMatch[1] ?? '');
|
|
304
|
+
if (transferMatch[2] === '/cancel' && req.method === 'POST') {
|
|
305
|
+
const b = await readBody(req);
|
|
306
|
+
return sendOk(res, await service.transferCancel(projectOfBody(b), id));
|
|
307
|
+
}
|
|
308
|
+
if (req.method === 'GET') {
|
|
309
|
+
// 进度快照含表清单/行数/错误详情:与 cancel 同样要求项目授权(query 带 project)
|
|
310
|
+
return sendOk(res, await service.transferProgress(projectOf(q), id));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
244
313
|
// 控制台事务会话(粘性连接):begin 发 token;exec 语义等同 execute(可能
|
|
245
314
|
// NEEDS_CONFIRMATION);commit/rollback 提交/回滚后销毁会话。trust 与 body
|
|
246
315
|
// 校验复用入口层既有链路。
|
|
@@ -412,6 +481,11 @@ function parseSsl(v) {
|
|
|
412
481
|
function optStr(v) {
|
|
413
482
|
return typeof v === 'string' && v !== '' ? v : undefined;
|
|
414
483
|
}
|
|
484
|
+
/** 可选正整数字段:undefined/非法值返回 undefined(服务层兜底默认) */
|
|
485
|
+
function num(v) {
|
|
486
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
|
|
487
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
488
|
+
}
|
|
415
489
|
function projectOf(q) {
|
|
416
490
|
return q.has('project') ? (q.get('project') ?? undefined) : undefined;
|
|
417
491
|
}
|
package/dist/manager.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { getAdapter } from './adapters/index.js';
|
|
3
3
|
import { ChallengeStore, classifyStatement, sqlHead, SQL_READ_HEADS, } from './guard/index.js';
|
|
4
|
-
import { mergeUrlCredentials, normalizeProjectKey, } from './store/index.js';
|
|
4
|
+
import { mergeUrlCredentials, normalizeProjectKey, DECRYPT_FAIL_MESSAGE, } from './store/index.js';
|
|
5
5
|
import { runScriptInChild } from './script/runner.js';
|
|
6
|
+
import { decryptPayload, encryptPayload } from './store/export-crypto.js';
|
|
7
|
+
import { startTransferTask } from './transfer.js';
|
|
6
8
|
/* ---------------- 错误与结果类型 ---------------- */
|
|
7
9
|
/** 携带机器可读 code 的业务错误(HTTP 层直接映射 {ok:false,error,code}) */
|
|
8
10
|
export class DbToolError extends Error {
|
|
@@ -27,6 +29,12 @@ function assertNonEmpty(v, label) {
|
|
|
27
29
|
}
|
|
28
30
|
/** 单连接并发控制台事务会话上限(每会话独占驱动池 + checkout 连接,防连接耗尽) */
|
|
29
31
|
const MAX_CONSOLE_SESSIONS_PER_CONN = 4;
|
|
32
|
+
/** 全局并发传输任务上限(每任务独立 reader/writer 连接,防连接耗尽) */
|
|
33
|
+
const MAX_TRANSFER_TASKS = 8;
|
|
34
|
+
/** 终态快照保留窗口:前端轮询间隔 800ms + 慢网络余量;窗口后定时清除(不无限滞留) */
|
|
35
|
+
const TRANSFER_SNAPSHOT_TTL_MS = 60_000;
|
|
36
|
+
/** 单任务表清单上限:statement 进 challenge 响应与审计(表名全量 join),防兆级放大 */
|
|
37
|
+
const MAX_TRANSFER_TABLES = 1000;
|
|
30
38
|
/* ---------------- 服务核心 ---------------- */
|
|
31
39
|
export class DbToolService {
|
|
32
40
|
store;
|
|
@@ -63,9 +71,11 @@ export class DbToolService {
|
|
|
63
71
|
if (!meta)
|
|
64
72
|
throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
|
|
65
73
|
// url/凭证/ssl 变更后旧适配器仍持旧连接串,必须作废重建;
|
|
66
|
-
// 控制台事务会话的专用适配器不在缓存里,单独回收(否则旧凭据最长存活到空闲 TTL
|
|
74
|
+
// 控制台事务会话的专用适配器不在缓存里,单独回收(否则旧凭据最长存活到空闲 TTL);
|
|
75
|
+
// 传输任务持独立连接,同样回收(凭证变更后不得按旧凭据继续读写)
|
|
67
76
|
void this.dropAdapters(id);
|
|
68
77
|
void this.dropConsoleSessions(id);
|
|
78
|
+
this.cancelTransferTasksForConn(id);
|
|
69
79
|
this.audit('', id, 'update_connection', id, 'none', false, true);
|
|
70
80
|
return meta;
|
|
71
81
|
}
|
|
@@ -75,6 +85,7 @@ export class DbToolService {
|
|
|
75
85
|
throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
|
|
76
86
|
void this.dropAdapters(id);
|
|
77
87
|
void this.dropConsoleSessions(id);
|
|
88
|
+
this.cancelTransferTasksForConn(id);
|
|
78
89
|
this.audit('', id, 'remove_connection', id, 'none', false, true);
|
|
79
90
|
return ok;
|
|
80
91
|
}
|
|
@@ -144,6 +155,60 @@ export class DbToolService {
|
|
|
144
155
|
throw this.toDriverError(e);
|
|
145
156
|
}
|
|
146
157
|
}
|
|
158
|
+
/* -- 连接导出/导入(口令加密,无需授权;口令不落审计) -- */
|
|
159
|
+
/** 口令下限:导出包含全部连接机密(明文密码/完整 URL),安全边界在服务端而非 UI */
|
|
160
|
+
static PASSPHRASE_MIN = 8;
|
|
161
|
+
/**
|
|
162
|
+
* 导出全部连接(含机密)为口令加密的 JSON 密文。
|
|
163
|
+
* 审计只记连接条数,绝不落口令与密文内容。
|
|
164
|
+
*/
|
|
165
|
+
exportConnections(passphrase) {
|
|
166
|
+
const pp = assertNonEmpty(passphrase, 'passphrase');
|
|
167
|
+
if (pp.length < DbToolService.PASSPHRASE_MIN) {
|
|
168
|
+
throw new DbToolError('INVALID_ARGUMENT', `口令长度至少 ${DbToolService.PASSPHRASE_MIN} 位`);
|
|
169
|
+
}
|
|
170
|
+
const bundle = this.store.connections.exportBundle();
|
|
171
|
+
const json = encryptPayload(bundle, pp);
|
|
172
|
+
this.audit('', '', 'export_connections', `导出 ${bundle.connections.length} 个连接`, 'none', false, true);
|
|
173
|
+
return json;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 从口令加密的 JSON 密文导入连接。口令错误或密文损坏统一报
|
|
177
|
+
* INVALID_ARGUMENT(DECRYPT_FAIL_MESSAGE)(不区分原因,防口令探测)。
|
|
178
|
+
*/
|
|
179
|
+
importConnections(encryptedJson, passphrase) {
|
|
180
|
+
const enc = assertNonEmpty(encryptedJson, 'json');
|
|
181
|
+
const pp = assertNonEmpty(passphrase, 'passphrase');
|
|
182
|
+
if (pp.length < DbToolService.PASSPHRASE_MIN) {
|
|
183
|
+
throw new DbToolError('INVALID_ARGUMENT', `口令长度至少 ${DbToolService.PASSPHRASE_MIN} 位`);
|
|
184
|
+
}
|
|
185
|
+
let bundle;
|
|
186
|
+
try {
|
|
187
|
+
bundle = decryptPayload(enc, pp);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// 口令错误/密文损坏/非合法 JSON 统一归一(decryptPayload 已统一文案,这里兜底)
|
|
191
|
+
throw new DbToolError('INVALID_ARGUMENT', DECRYPT_FAIL_MESSAGE);
|
|
192
|
+
}
|
|
193
|
+
// 结构校验:必须是含 connections 数组的对象,且每个元素为带非空 string id/kind 的对象。
|
|
194
|
+
// 畸形元素会让 importBundle 在 try 之外抛 TypeError(绕开统一文案),
|
|
195
|
+
// 或以 undefined id 静默落库成 UI 删不掉的幽灵记录
|
|
196
|
+
const conns = bundle?.connections;
|
|
197
|
+
if (!bundle ||
|
|
198
|
+
typeof bundle !== 'object' ||
|
|
199
|
+
!Array.isArray(conns) ||
|
|
200
|
+
conns.some((c) => c === null ||
|
|
201
|
+
typeof c !== 'object' ||
|
|
202
|
+
typeof c.id !== 'string' ||
|
|
203
|
+
c.id === '' ||
|
|
204
|
+
typeof c.kind !== 'string' ||
|
|
205
|
+
c.kind === '')) {
|
|
206
|
+
throw new DbToolError('INVALID_ARGUMENT', DECRYPT_FAIL_MESSAGE);
|
|
207
|
+
}
|
|
208
|
+
const summary = this.store.connections.importBundle(bundle);
|
|
209
|
+
this.audit('', '', 'import_connections', `导入 ${summary.imported} 个连接,跳过 ${summary.skipped.length} 个,失败 ${summary.errors.length} 个`, 'none', false, true);
|
|
210
|
+
return summary;
|
|
211
|
+
}
|
|
147
212
|
/* -- 授权管理 -- */
|
|
148
213
|
/** 项目路径归一化(/api/project-context 无会话解析器时的回退路径) */
|
|
149
214
|
projectKey(projectPath) {
|
|
@@ -166,6 +231,7 @@ export class DbToolService {
|
|
|
166
231
|
this.store.grants.revoke(key, connId);
|
|
167
232
|
void this.dropAdapters(connId);
|
|
168
233
|
void this.dropConsoleSessions(connId);
|
|
234
|
+
this.cancelTransferTasksForConn(connId); // 撤销后任务立即止损(否则写端全速跑完,管理员无系统内手段)
|
|
169
235
|
this.audit(key, connId, 'revoke', connId, 'none', false, true);
|
|
170
236
|
}
|
|
171
237
|
/* -- 浏览(ro 即可) -- */
|
|
@@ -657,6 +723,177 @@ export class DbToolService {
|
|
|
657
723
|
}
|
|
658
724
|
}
|
|
659
725
|
}
|
|
726
|
+
/* -- 数据传输(并行任务) -- */
|
|
727
|
+
/** 运行中的传输任务(taskId → 条目);终态后保留一个轮询窗口再清除(见 finished 回调) */
|
|
728
|
+
transferTasks = new Map();
|
|
729
|
+
async transferStart(projectPath, input, challengeId) {
|
|
730
|
+
const tables = input.tables;
|
|
731
|
+
if (!Array.isArray(tables) || tables.length === 0) {
|
|
732
|
+
throw new DbToolError('INVALID_ARGUMENT', 'tables 必须是非空字符串数组');
|
|
733
|
+
}
|
|
734
|
+
// 编排层按表名反查进度条目:重复名会让两遍传输共享同一进度对象且第二条永久 pending
|
|
735
|
+
if (new Set(tables).size !== tables.length) {
|
|
736
|
+
throw new DbToolError('INVALID_ARGUMENT', 'tables 存在重复表名,请去重后重试');
|
|
737
|
+
}
|
|
738
|
+
if (tables.length > MAX_TRANSFER_TABLES) {
|
|
739
|
+
throw new DbToolError('INVALID_ARGUMENT', `单任务表数超上限(${tables.length} > ${MAX_TRANSFER_TABLES}),请分批传输`);
|
|
740
|
+
}
|
|
741
|
+
const writeMode = input.writeMode ?? 'insert';
|
|
742
|
+
if (writeMode !== 'insert' && writeMode !== 'ignore' && writeMode !== 'replace' && writeMode !== 'truncate') {
|
|
743
|
+
throw new DbToolError('INVALID_ARGUMENT', `writeMode 非法: ${String(writeMode)}(insert/ignore/replace/truncate)`);
|
|
744
|
+
}
|
|
745
|
+
// 源连接只读授权即可(传输读端自建专用连接);目标连接需写
|
|
746
|
+
const { key: srcKey } = await this.authorize(projectPath, input.sourceConnId, false);
|
|
747
|
+
const { key: dstKey } = await this.authorize(projectPath, input.targetConnId, true);
|
|
748
|
+
const source = this.requireConn(input.sourceConnId);
|
|
749
|
+
const target = this.requireConn(input.targetConnId);
|
|
750
|
+
// 定位:optStr 语义(存在则 trim 非空才收,否则忽略);缺省 = 连接默认上下文
|
|
751
|
+
const optLoc = (v) => {
|
|
752
|
+
const s = typeof v === 'string' ? v.trim() : '';
|
|
753
|
+
return s !== '' ? s : undefined;
|
|
754
|
+
};
|
|
755
|
+
const srcDb = optLoc(input.sourceDatabase);
|
|
756
|
+
const srcSchema = optLoc(input.sourceSchema);
|
|
757
|
+
const dstDb = optLoc(input.targetDatabase);
|
|
758
|
+
const dstSchema = optLoc(input.targetSchema);
|
|
759
|
+
// 定位进 statement(start/finish/cancel 三链审计与 challenge 绑定共用一条),
|
|
760
|
+
// 首次生成与带 challengeId 重放同 input 必同值;schema 附着于库(前端联动恒先选库)
|
|
761
|
+
const locSeg = (db, schema) => db ? (schema ? `${db}.${schema}` : db) : '';
|
|
762
|
+
const locPart = [
|
|
763
|
+
srcDb ? ` 源=${locSeg(srcDb, srcSchema)}` : '',
|
|
764
|
+
dstDb ? ` 目标=${locSeg(dstDb, dstSchema)}` : '',
|
|
765
|
+
].join('');
|
|
766
|
+
const statement = `数据传输 ${source.meta.kind}(${source.meta.id}) → ${target.meta.kind}(${target.meta.id}) 表[${tables.join(', ')}] 模式=${writeMode}${locPart}`;
|
|
767
|
+
// 危险操作确认(批量建表+写入,对齐 runGuarded 的 danger 分支语义)
|
|
768
|
+
const scope = { connId: input.targetConnId, projectKey: dstKey };
|
|
769
|
+
if (!challengeId) {
|
|
770
|
+
const { id: cid } = this.challenges.create(statement, scope);
|
|
771
|
+
this.audit(dstKey, input.targetConnId, 'transfer', statement, 'danger', false, false, 'NEEDS_CONFIRMATION');
|
|
772
|
+
return {
|
|
773
|
+
needConfirmation: true,
|
|
774
|
+
challengeId: cid,
|
|
775
|
+
statement,
|
|
776
|
+
danger: 'danger',
|
|
777
|
+
reason: writeMode === 'truncate'
|
|
778
|
+
? `【危险】将清空目标库 ${target.meta.kind}(${target.meta.id}) 中 ${tables.length} 张表的全部已有数据,随后重新写入,清空不可恢复,需要确认`
|
|
779
|
+
: `将向目标库 ${target.meta.kind}(${target.meta.id}) 建表并批量写入 ${tables.length} 张表,需要确认`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (!this.challenges.consume(challengeId, statement, scope)) {
|
|
783
|
+
this.audit(dstKey, input.targetConnId, 'transfer', statement, 'danger', false, false, 'INVALID_CHALLENGE');
|
|
784
|
+
throw new DbToolError('INVALID_CHALLENGE', '确认凭据无效(不存在、已使用、已过期或语句已变更),请重新发起');
|
|
785
|
+
}
|
|
786
|
+
// —— 同步段:预算检查 + 注册之间零 await,杜绝并发 start 双过检查(TOCTOU)——
|
|
787
|
+
this.sweepTransferTasks();
|
|
788
|
+
const running = [...this.transferTasks.values()].filter((e) => e.task.snapshot().status === 'running');
|
|
789
|
+
if (running.length >= MAX_TRANSFER_TASKS) {
|
|
790
|
+
throw new DbToolError('INVALID_ARGUMENT', `全局并发传输任务已达上限(${MAX_TRANSFER_TASKS}),请等待既有任务完成`);
|
|
791
|
+
}
|
|
792
|
+
if (running.some((e) => e.targetConnId === input.targetConnId)) {
|
|
793
|
+
throw new DbToolError('INVALID_ARGUMENT', '该目标连接已有传输任务在执行(同目标并发写入会互踩),请等待完成或先取消');
|
|
794
|
+
}
|
|
795
|
+
const task = startTransferTask(source, target, {
|
|
796
|
+
tables,
|
|
797
|
+
writeMode,
|
|
798
|
+
...(input.batchSize !== undefined ? { batchSize: input.batchSize } : {}),
|
|
799
|
+
...(input.tableConcurrency !== undefined ? { tableConcurrency: input.tableConcurrency } : {}),
|
|
800
|
+
...(input.shardConcurrency !== undefined ? { shardConcurrency: input.shardConcurrency } : {}),
|
|
801
|
+
...(input.overwriteStructure === true ? { overwriteStructure: true } : {}),
|
|
802
|
+
...(srcDb !== undefined || srcSchema !== undefined ? { sourceDatabase: srcDb, sourceSchema: srcSchema } : {}),
|
|
803
|
+
...(dstDb !== undefined || dstSchema !== undefined ? { targetDatabase: dstDb, targetSchema: dstSchema } : {}),
|
|
804
|
+
// 传输日志:任务开始/表完成/进度节流/终态逐行落 <我的文档>/DSH/transfer-log.jsonl
|
|
805
|
+
onLog: (ev) => {
|
|
806
|
+
this.store.transferLog.append({
|
|
807
|
+
type: ev.type,
|
|
808
|
+
taskId: task.id,
|
|
809
|
+
projectPathKey: dstKey,
|
|
810
|
+
statement,
|
|
811
|
+
...(ev.status !== undefined ? { status: ev.status } : {}),
|
|
812
|
+
...(ev.table !== undefined ? { table: ev.table } : {}),
|
|
813
|
+
...(ev.rows !== undefined ? { rows: ev.rows } : {}),
|
|
814
|
+
...(ev.totalRows !== undefined ? { totalRows: ev.totalRows } : {}),
|
|
815
|
+
...(ev.tables !== undefined ? { tables: ev.tables } : {}),
|
|
816
|
+
...(ev.failures !== undefined ? { failures: ev.failures } : {}),
|
|
817
|
+
});
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
this.transferTasks.set(task.id, { task, sourceConnId: input.sourceConnId, targetConnId: input.targetConnId, projectKey: dstKey, statement });
|
|
821
|
+
this.audit(dstKey, input.targetConnId, 'transfer', statement, 'danger', true, true);
|
|
822
|
+
void task.finished.then((s) => {
|
|
823
|
+
const failed = s.failures.length > 0;
|
|
824
|
+
this.audit(dstKey, input.targetConnId, 'transfer_finish', statement, 'danger', true, !failed, failed ? `${s.failures.length} 个分片/表失败` : undefined);
|
|
825
|
+
// 终态不无限滞留 Map:保留一个轮询窗口(前端 800ms 轮询 + 慢网络余量)供拉到
|
|
826
|
+
// 终态快照,之后定时清除;start 时的 sweepTransferTasks 兜底清同一批条目
|
|
827
|
+
setTimeout(() => {
|
|
828
|
+
this.transferTasks.delete(task.id);
|
|
829
|
+
}, TRANSFER_SNAPSHOT_TTL_MS);
|
|
830
|
+
});
|
|
831
|
+
return { taskId: task.id };
|
|
832
|
+
}
|
|
833
|
+
async transferProgress(projectPath, taskId) {
|
|
834
|
+
const entry = this.transferTasks.get(taskId);
|
|
835
|
+
if (!entry)
|
|
836
|
+
throw new DbToolError('NOT_FOUND', `传输任务不存在或已结束: ${taskId}`);
|
|
837
|
+
// 快照含表清单/行数/错误详情,非公开信息:与数据面接口一致要求项目授权,
|
|
838
|
+
// 且必须与发起项目同 key(taskId 可枚举,不能凭 id 越项目读取)
|
|
839
|
+
const { key } = await this.authorize(projectPath, entry.targetConnId, false);
|
|
840
|
+
if (key !== entry.projectKey) {
|
|
841
|
+
throw new DbToolError('UNAUTHORIZED_PROJECT', `传输任务不属于该项目: ${taskId}`);
|
|
842
|
+
}
|
|
843
|
+
return entry.task.snapshot();
|
|
844
|
+
}
|
|
845
|
+
async transferCancel(projectPath, taskId) {
|
|
846
|
+
const entry = this.transferTasks.get(taskId);
|
|
847
|
+
if (!entry)
|
|
848
|
+
throw new DbToolError('NOT_FOUND', `传输任务不存在或已结束: ${taskId}`);
|
|
849
|
+
// 取消需对目标连接有写授权(能发起即能取消,授权语义对齐)
|
|
850
|
+
await this.authorize(projectPath, entry.targetConnId, true);
|
|
851
|
+
entry.task.cancel();
|
|
852
|
+
// 取消是影响目标库状态的操作:与 start/finish 同链审计(ok 以 cancelled 语义恒真)
|
|
853
|
+
this.audit(entry.projectKey, entry.targetConnId, 'transfer_cancel', entry.statement, 'danger', true, true);
|
|
854
|
+
return { cancelled: true };
|
|
855
|
+
}
|
|
856
|
+
/** 授权/连接变更时回收该连接参与的传输任务(对齐控制台会话的对称回收;cancel 为协作式,检查点退出) */
|
|
857
|
+
cancelTransferTasksForConn(connId) {
|
|
858
|
+
for (const entry of this.transferTasks.values()) {
|
|
859
|
+
if (entry.sourceConnId === connId || entry.targetConnId === connId)
|
|
860
|
+
entry.task.cancel();
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** 传输历史:最近 n 条任务终态(可选按项目过滤);对齐审计查询的公开语义 */
|
|
864
|
+
transferHistory(projectPath, limit = 50) {
|
|
865
|
+
const n = Math.min(Math.max(1, Math.floor(limit) || 50), 200);
|
|
866
|
+
return projectPath && projectPath.trim() !== ''
|
|
867
|
+
? this.store.transferLog.tail(n, normalizeProjectKey(projectPath), 'finish')
|
|
868
|
+
: this.store.transferLog.tail(n, undefined, 'finish');
|
|
869
|
+
}
|
|
870
|
+
/** 实时进度:全部运行中任务的快照(可选按项目过滤),供「实时进度」子标签轮询 */
|
|
871
|
+
transferLive(projectPath) {
|
|
872
|
+
const key = projectPath && projectPath.trim() !== '' ? normalizeProjectKey(projectPath) : undefined;
|
|
873
|
+
return [...this.transferTasks.entries()]
|
|
874
|
+
.filter(([, e]) => e.task.snapshot().status === 'running' && (!key || e.projectKey === key))
|
|
875
|
+
.map(([taskId, e]) => ({ taskId, snapshot: e.task.snapshot() }));
|
|
876
|
+
}
|
|
877
|
+
/** 传输日志:最近 n 条全事件行(start/表完成/进度/终态),供日志标签实时查看 */
|
|
878
|
+
transferLogTail(projectPath, limit = 200) {
|
|
879
|
+
const n = Math.min(Math.max(1, Math.floor(limit) || 200), 2000);
|
|
880
|
+
return projectPath && projectPath.trim() !== ''
|
|
881
|
+
? this.store.transferLog.tail(n, normalizeProjectKey(projectPath))
|
|
882
|
+
: this.store.transferLog.tail(n);
|
|
883
|
+
}
|
|
884
|
+
/** 清除传输历史:全量清除(历史区与文件同步清空)。留审计。 */
|
|
885
|
+
transferHistoryClear() {
|
|
886
|
+
const removed = this.store.transferLog.clear();
|
|
887
|
+
this.audit('', '', 'transfer_history_clear', '清除全部传输历史', 'none', false, true);
|
|
888
|
+
return { removed };
|
|
889
|
+
}
|
|
890
|
+
/** 清理终态任务(start 时调用;快照仅在 running 期间可查) */
|
|
891
|
+
sweepTransferTasks() {
|
|
892
|
+
for (const [id, entry] of this.transferTasks) {
|
|
893
|
+
if (entry.task.snapshot().status !== 'running')
|
|
894
|
+
this.transferTasks.delete(id);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
660
897
|
adapterFor(connId, mode) {
|
|
661
898
|
const cacheKey = `${connId}@mode=${mode}`;
|
|
662
899
|
let p = this.adapterCache.get(cacheKey);
|
|
@@ -10,6 +10,16 @@ import * as path from 'node:path';
|
|
|
10
10
|
import { readJson, writeJsonAtomic } from './io.js';
|
|
11
11
|
/** fields 中被视为机密的键,落盘前拆出到 secrets.json */
|
|
12
12
|
const PASSWORD_KEY = 'password';
|
|
13
|
+
/** id 冲突哨兵错误:importBundle 以此 instanceof 识别跳过项,
|
|
14
|
+
* 替代对 create() 中文错误文案的字符串匹配(文案改动不破坏幂等契约) */
|
|
15
|
+
export class DuplicateConnectionError extends Error {
|
|
16
|
+
connId;
|
|
17
|
+
constructor(connId) {
|
|
18
|
+
super(`连接已存在: ${connId}`);
|
|
19
|
+
this.connId = connId;
|
|
20
|
+
this.name = 'DuplicateConnectionError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
13
23
|
/** authority 段定位:scheme:// 到首个 /?#(兼容可选 jdbc: 前缀,如 jdbc:gaussdb://),
|
|
14
24
|
* 捕获组 1 为 scheme:// 部分、组 2 为 authority(多主机逗号串整体落在该段内)。
|
|
15
25
|
* redactUrl 与 mergeUrlCredentials 的字符串回退注入共用。 */
|
|
@@ -178,11 +188,11 @@ export class ConnectionStore {
|
|
|
178
188
|
const rec = this.findRec(this.load(), id);
|
|
179
189
|
return rec ? toMeta(rec, this.hasPassword(id)) : undefined;
|
|
180
190
|
}
|
|
181
|
-
/** 新建连接;id
|
|
191
|
+
/** 新建连接;id 已存在时抛 DuplicateConnectionError */
|
|
182
192
|
create(input) {
|
|
183
193
|
const data = this.load();
|
|
184
194
|
if (this.findRec(data, input.id)) {
|
|
185
|
-
throw new
|
|
195
|
+
throw new DuplicateConnectionError(input.id);
|
|
186
196
|
}
|
|
187
197
|
const rec = { id: input.id, kind: input.kind };
|
|
188
198
|
if (input.name !== undefined)
|
|
@@ -296,4 +306,77 @@ export class ConnectionStore {
|
|
|
296
306
|
rc.ssl = rec.ssl;
|
|
297
307
|
return rc;
|
|
298
308
|
}
|
|
309
|
+
/** 导出连接迁移包:全部连接记录 + 对应机密(含明文密码)。
|
|
310
|
+
* 调用方必须将整个返回值经 export-crypto 加密后方可落盘/传输,
|
|
311
|
+
* 本方法本身不加密(加密口令属于 HTTP 层入参,不应侵入存储层)。 */
|
|
312
|
+
exportBundle() {
|
|
313
|
+
const connections = this.load().connections;
|
|
314
|
+
const secrets = {};
|
|
315
|
+
for (const rec of connections) {
|
|
316
|
+
const sec = this.secrets.get(rec.id);
|
|
317
|
+
if (sec !== undefined)
|
|
318
|
+
secrets[rec.id] = sec;
|
|
319
|
+
}
|
|
320
|
+
return { connections, secrets };
|
|
321
|
+
}
|
|
322
|
+
/** 导入连接迁移包:逐条走既有 create()(机密拆分/脱敏逻辑全复用)。
|
|
323
|
+
* - id 冲突(「连接已存在」)→ 记入 skipped,不覆盖已有数据;
|
|
324
|
+
* - 其他异常 → 记入 errors 并继续处理后续条目;
|
|
325
|
+
* - 幂等可重入:重复导入同一 bundle,已存在的全部落入 skipped。 */
|
|
326
|
+
importBundle(bundle) {
|
|
327
|
+
const result = { imported: 0, skipped: [], errors: [] };
|
|
328
|
+
for (const rec of bundle.connections) {
|
|
329
|
+
// per-record 校验:解密成功的包也可能内容畸形(schema 漂移/手工构造),
|
|
330
|
+
// 缺 id/kind 的记录不落库,计入 errors
|
|
331
|
+
if (rec === null ||
|
|
332
|
+
typeof rec !== 'object' ||
|
|
333
|
+
typeof rec.id !== 'string' ||
|
|
334
|
+
rec.id === '' ||
|
|
335
|
+
typeof rec.kind !== 'string' ||
|
|
336
|
+
rec.kind === '') {
|
|
337
|
+
result.errors.push({ id: String(rec?.id ?? ''), message: '记录结构非法(缺少 id/kind)' });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const sec = bundle.secrets?.[rec.id];
|
|
341
|
+
try {
|
|
342
|
+
// 重建 create 入参:脱敏 URL 回换完整 URL(机密在 sec.url 中);
|
|
343
|
+
// fields 模式密码经 fields.password 传入由 create 自动拆分到 secrets
|
|
344
|
+
this.create({
|
|
345
|
+
id: rec.id,
|
|
346
|
+
kind: rec.kind,
|
|
347
|
+
name: rec.name,
|
|
348
|
+
url: sec?.url,
|
|
349
|
+
fields: sec?.password !== undefined ? { ...(rec.fields ?? {}), password: sec.password } : rec.fields,
|
|
350
|
+
ssl: rec.ssl,
|
|
351
|
+
});
|
|
352
|
+
// 覆盖式写回:create 成功后该 id 必无冲突,先清掉既有/孤儿机密
|
|
353
|
+
// (create 崩溃窗口可能残留旧条目),再整体重写,确保导入后机密与包一致。
|
|
354
|
+
// 合并写会残留旧键(如旧 url),适配器优先用 rc.url,会被静默错连旧主机。
|
|
355
|
+
this.secrets.delete(rec.id);
|
|
356
|
+
try {
|
|
357
|
+
if (sec !== undefined)
|
|
358
|
+
this.secrets.set(rec.id, sec);
|
|
359
|
+
}
|
|
360
|
+
catch (secErr) {
|
|
361
|
+
// 写回机密失败:连接行已落盘,回滚避免「errors 里却已存在」的半导入门状态
|
|
362
|
+
try {
|
|
363
|
+
this.remove(rec.id);
|
|
364
|
+
}
|
|
365
|
+
catch { /* 尽力而为 */ }
|
|
366
|
+
throw secErr;
|
|
367
|
+
}
|
|
368
|
+
result.imported += 1;
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
372
|
+
if (err instanceof DuplicateConnectionError) {
|
|
373
|
+
result.skipped.push(rec.id);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
result.errors.push({ id: rec.id, message });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
299
382
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 连接导出/导入的口令加解密(纯函数,仅用 Node stdlib crypto)。
|
|
3
|
+
*
|
|
4
|
+
* 方案:
|
|
5
|
+
* - KDF:scryptSync(passphrase, salt, 32) 派生 AES-256 密钥;
|
|
6
|
+
* - 算法:AES-256-GCM(机密性 + 完整性一体,tag 防篡改);
|
|
7
|
+
* - iv:12 字节随机(GCM 推荐长度);salt:16 字节随机;
|
|
8
|
+
* - 输出为 JSON 字符串:{ v: 1, kdf: 'scrypt', salt, iv, tag, data },二进制段全部 base64。
|
|
9
|
+
*
|
|
10
|
+
* 失败统一抛 Error('口令错误或文件已损坏'),不向外泄漏是口令错还是文件坏,
|
|
11
|
+
* 避免给攻击者侧信道。口令本身为空/非字符串时单独抛 Error(参数错误,非解密失败)。
|
|
12
|
+
*/
|
|
13
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
14
|
+
/** 解密失败/文件损坏的统一错误信息 */
|
|
15
|
+
export const DECRYPT_FAIL_MESSAGE = '口令错误或文件已损坏';
|
|
16
|
+
/** 口令断言:必须为非空字符串 */
|
|
17
|
+
function assertPassphrase(passphrase) {
|
|
18
|
+
if (typeof passphrase !== 'string' || passphrase.length === 0) {
|
|
19
|
+
throw new Error('口令必须为非空字符串');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** 加密任意可 JSON 序列化的 payload,返回信封 JSON 字符串 */
|
|
23
|
+
export function encryptPayload(plain, passphrase) {
|
|
24
|
+
assertPassphrase(passphrase);
|
|
25
|
+
const salt = randomBytes(16);
|
|
26
|
+
// 显式固定 scrypt 成本参数(与 Node 当前默认一致),加解密两侧同步使用,
|
|
27
|
+
// 避免导出包跨运行时/跨 Node 版本时因默认值漂移而无法解密
|
|
28
|
+
const key = scryptSync(passphrase, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
29
|
+
const iv = randomBytes(12);
|
|
30
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
31
|
+
const data = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]);
|
|
32
|
+
const box = {
|
|
33
|
+
v: 1,
|
|
34
|
+
kdf: 'scrypt',
|
|
35
|
+
salt: salt.toString('base64'),
|
|
36
|
+
iv: iv.toString('base64'),
|
|
37
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
38
|
+
data: data.toString('base64'),
|
|
39
|
+
};
|
|
40
|
+
return JSON.stringify(box);
|
|
41
|
+
}
|
|
42
|
+
/** 结构校验:缺字段/类型不符即视为文件损坏 */
|
|
43
|
+
function isEncryptedBox(x) {
|
|
44
|
+
if (x === null || typeof x !== 'object')
|
|
45
|
+
return false;
|
|
46
|
+
const b = x;
|
|
47
|
+
return (b.v === 1 &&
|
|
48
|
+
b.kdf === 'scrypt' &&
|
|
49
|
+
typeof b.salt === 'string' &&
|
|
50
|
+
typeof b.iv === 'string' &&
|
|
51
|
+
typeof b.tag === 'string' &&
|
|
52
|
+
typeof b.data === 'string');
|
|
53
|
+
}
|
|
54
|
+
/** 解密信封 JSON,返回原始 payload;口令错误或任一环节损坏统一抛 DECRYPT_FAIL_MESSAGE */
|
|
55
|
+
export function decryptPayload(encryptedJson, passphrase) {
|
|
56
|
+
assertPassphrase(passphrase);
|
|
57
|
+
try {
|
|
58
|
+
const box = JSON.parse(encryptedJson);
|
|
59
|
+
if (!isEncryptedBox(box))
|
|
60
|
+
throw new Error('结构非法');
|
|
61
|
+
const salt = Buffer.from(box.salt, 'base64');
|
|
62
|
+
const iv = Buffer.from(box.iv, 'base64');
|
|
63
|
+
const tag = Buffer.from(box.tag, 'base64');
|
|
64
|
+
const data = Buffer.from(box.data, 'base64');
|
|
65
|
+
// base64 解码静默容错,需显式校验解码后长度(iv 12B / tag 16B / salt 非空)
|
|
66
|
+
if (iv.length !== 12 || tag.length !== 16 || salt.length === 0)
|
|
67
|
+
throw new Error('长度非法');
|
|
68
|
+
const key = scryptSync(passphrase, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
69
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
70
|
+
decipher.setAuthTag(tag);
|
|
71
|
+
const plain = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
72
|
+
return JSON.parse(plain.toString('utf8'));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// 对外统一文案(防侧信道),原始错误挂 cause 供服务端日志区分失败模式
|
|
76
|
+
throw new Error(DECRYPT_FAIL_MESSAGE, { cause: err });
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/store/index.js
CHANGED
|
@@ -13,12 +13,14 @@ import * as fs from 'node:fs';
|
|
|
13
13
|
import * as os from 'node:os';
|
|
14
14
|
import * as path from 'node:path';
|
|
15
15
|
import { AuditLog } from './audit.js';
|
|
16
|
+
import { TransferLog, transferLogDir } from './transfer-history.js';
|
|
16
17
|
import { ConnectionStore } from './connections.js';
|
|
17
18
|
import { GrantStore } from './grants.js';
|
|
18
19
|
import { chmodBestEffort } from './io.js';
|
|
19
20
|
import { SecretsBox } from './secrets.js';
|
|
20
21
|
export { normalizeProjectKey } from './normalize.js';
|
|
21
|
-
export { mergeUrlCredentials, redactUrl } from './connections.js';
|
|
22
|
+
export { mergeUrlCredentials, redactUrl, DuplicateConnectionError } from './connections.js';
|
|
23
|
+
export { encryptPayload, decryptPayload, DECRYPT_FAIL_MESSAGE } from './export-crypto.js';
|
|
22
24
|
export { AuditLog, ConnectionStore, GrantStore, SecretsBox };
|
|
23
25
|
export class DbToolStore {
|
|
24
26
|
/** 数据目录:$DSH_HOME/db-tool */
|
|
@@ -27,6 +29,7 @@ export class DbToolStore {
|
|
|
27
29
|
secrets;
|
|
28
30
|
grants;
|
|
29
31
|
audit;
|
|
32
|
+
transferLog;
|
|
30
33
|
constructor(homeDir) {
|
|
31
34
|
// || 而非 ??: DSH_HOME=""(空串)视为未设置,避免产出相对路径 'db-tool/'
|
|
32
35
|
const home = homeDir ?? (process.env['DSH_HOME'] || path.join(os.homedir(), '.dsh'));
|
|
@@ -36,6 +39,9 @@ export class DbToolStore {
|
|
|
36
39
|
this.secrets = new SecretsBox(this.dir);
|
|
37
40
|
this.grants = new GrantStore(this.dir);
|
|
38
41
|
this.audit = new AuditLog(this.dir);
|
|
42
|
+
// 传输历史独立于 store:存到 <我的文档>/DSH/ 供用户直接查看;
|
|
43
|
+
// vitest 运行时仍走 temp home,避免测试写用户真实文档目录
|
|
44
|
+
this.transferLog = new TransferLog(process.env.VITEST === 'true' ? this.dir : transferLogDir());
|
|
39
45
|
this.connections = new ConnectionStore(this.dir, this.secrets, this.grants);
|
|
40
46
|
}
|
|
41
47
|
}
|