dsh-db-tool 0.1.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/README.md +66 -0
- package/client/README.md +53 -0
- package/client/client.js +1128 -0
- package/cordis.patch.yml +6 -0
- package/dist/adapters/dmdb/index.js +326 -0
- package/dist/adapters/gaussdb/index.js +32 -0
- package/dist/adapters/index.js +41 -0
- package/dist/adapters/mongodb/index.js +508 -0
- package/dist/adapters/mysql/index.js +222 -0
- package/dist/adapters/oracle/index.js +369 -0
- package/dist/adapters/postgresql/index.js +15 -0
- package/dist/adapters/redis/index.js +492 -0
- package/dist/adapters/sql-shared/common.js +69 -0
- package/dist/adapters/sql-shared/pg-like.js +170 -0
- package/dist/adapters/sqlite/index.js +106 -0
- package/dist/adapters/types.js +1 -0
- package/dist/guard/index.js +221 -0
- package/dist/http/index.js +396 -0
- package/dist/index.js +130 -0
- package/dist/manager.js +469 -0
- package/dist/script/runner.js +147 -0
- package/dist/script/worker.cjs +104 -0
- package/dist/store/audit.js +41 -0
- package/dist/store/connections.js +167 -0
- package/dist/store/grants.js +82 -0
- package/dist/store/index.js +41 -0
- package/dist/store/io.js +66 -0
- package/dist/store/normalize.js +22 -0
- package/dist/store/secrets.js +42 -0
- package/docs/api-contract.md +76 -0
- package/docs/apple-redesign-spec.md +80 -0
- package/docs/dsh-market-submission/mengqi1436__dsh-db-tool.yml +6 -0
- package/docs/install.md +75 -0
- package/docs/review-findings.md +95 -0
- package/docs/skill.md +48 -0
- package/package.json +70 -0
- package/scripts/build-gaussdb.ps1 +72 -0
- package/scripts/build-gaussdb.sh +53 -0
- package/scripts/diag-profile.mjs +21 -0
- package/scripts/verify-host.mjs +53 -0
- package/skills/db-admin/SKILL.md +138 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/http:数据库工具 HTTP API(docs/api-contract.md v1)。
|
|
3
|
+
*
|
|
4
|
+
* 默认挂载方式:由插件入口经 DSH webServer(kind:'prefix',/dsh-db-tool/api)
|
|
5
|
+
* 调用 handleDbToolRequest——前端同源相对路径 fetch 即可达。
|
|
6
|
+
*
|
|
7
|
+
* trust 校验(等价移植 dsh-ssh-tunnel lib/shared/http-trust.js,自包含):
|
|
8
|
+
* - Host 必须是 loopback(或 ctx.webRuntime.trustedHosts 中的条目);
|
|
9
|
+
* - 带 Origin/Referer 时其 host 必须是 loopback(且同端口)或受信条目。
|
|
10
|
+
* - 0.0.0.0 不是 loopback。违者 403 FORBIDDEN。
|
|
11
|
+
*
|
|
12
|
+
* 统一响应:成功 {ok:true,data};失败 {ok:false,error,code}(业务错 HTTP 200,
|
|
13
|
+
* code 供前端分支);协议级错误(403/404/405/400)用 HTTP 状态码。
|
|
14
|
+
* NEEDS_CONFIRMATION 返回扁平 {ok:false,code,challengeId,statement,danger,reason}。
|
|
15
|
+
*/
|
|
16
|
+
import * as http from 'node:http';
|
|
17
|
+
import { DbToolError } from '../manager.js';
|
|
18
|
+
export const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
/* ---------------- trust 校验(自包含,逻辑等价 dsh-ssh-tunnel http-trust.js) ---------------- */
|
|
20
|
+
export function isLoopbackHostname(hostname) {
|
|
21
|
+
const h = String(hostname ?? '')
|
|
22
|
+
.replace(/^\[|\]$/g, '')
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1';
|
|
25
|
+
}
|
|
26
|
+
function originHostPort(originHeader) {
|
|
27
|
+
try {
|
|
28
|
+
const u = new URL(String(originHeader));
|
|
29
|
+
const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
30
|
+
const port = u.port || (u.protocol === 'https:' ? '443' : u.protocol === 'http:' ? '80' : '');
|
|
31
|
+
return { host, port, hostHeaderLike: port !== '' ? `${host}:${port}` : host };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Host 必须 loopback 或在 trustedHosts;Origin/Referer 存在时必须 loopback 同端口或受信 */
|
|
38
|
+
export function isTrustedRequest(req, trustedHosts) {
|
|
39
|
+
try {
|
|
40
|
+
const hostHeader = req.headers?.host;
|
|
41
|
+
if (!hostHeader)
|
|
42
|
+
return false;
|
|
43
|
+
const url = new URL(`http://${hostHeader}`);
|
|
44
|
+
const reqHost = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
45
|
+
const list = Array.isArray(trustedHosts) ? trustedHosts : [];
|
|
46
|
+
const hostOk = isLoopbackHostname(reqHost) ||
|
|
47
|
+
list.some((entry) => {
|
|
48
|
+
const e = String(entry);
|
|
49
|
+
return e === hostHeader || e === url.hostname || e === url.host;
|
|
50
|
+
});
|
|
51
|
+
if (!hostOk)
|
|
52
|
+
return false;
|
|
53
|
+
const origin = req.headers?.origin ?? req.headers?.referer ?? '';
|
|
54
|
+
if (origin === '')
|
|
55
|
+
return true;
|
|
56
|
+
const parsed = originHostPort(origin);
|
|
57
|
+
if (!parsed)
|
|
58
|
+
return false;
|
|
59
|
+
if (isLoopbackHostname(parsed.host) && isLoopbackHostname(reqHost)) {
|
|
60
|
+
const reqPort = url.port || '80';
|
|
61
|
+
if (parsed.port !== '' && parsed.port !== reqPort)
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return list.some((entry) => {
|
|
66
|
+
const e = String(entry);
|
|
67
|
+
return e === parsed.hostHeaderLike || e === parsed.host || e === origin;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 处理一个数据库工具 API 请求(可在 webServer prefix handler 中直接调用)。
|
|
76
|
+
* 兼容两种路径形态:独立挂载 /api/...,prefix 挂载 /dsh-db-tool/api/...
|
|
77
|
+
* (取 pathname 中 /api 起的路由段匹配)。
|
|
78
|
+
*/
|
|
79
|
+
export async function handleDbToolRequest(req, res, service, opts) {
|
|
80
|
+
try {
|
|
81
|
+
// 1) trust 校验(DNS rebinding / 跨站防护)
|
|
82
|
+
if (!isTrustedRequest(req, opts?.trustedHosts)) {
|
|
83
|
+
send(res, 403, { ok: false, error: '非本机来源,已拒绝', code: 'FORBIDDEN' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const hostHeader = req.headers.host ?? '';
|
|
87
|
+
if (req.method === 'OPTIONS') {
|
|
88
|
+
res.writeHead(204, { 'Access-Control-Max-Age': '600' });
|
|
89
|
+
res.end();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// 2) 路径规范化:截取 /api 起的路由段
|
|
93
|
+
const url = new URL(req.url ?? '/', `http://${hostHeader || '127.0.0.1'}`);
|
|
94
|
+
let rawPath = url.pathname;
|
|
95
|
+
if (!rawPath.endsWith('/api') && !rawPath.includes('/api/')) {
|
|
96
|
+
send(res, 404, { ok: false, error: `未知路由: ${req.method} ${rawPath}`, code: 'NOT_FOUND' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const apiIdx = rawPath.indexOf('/api/');
|
|
100
|
+
if (rawPath.endsWith('/api')) {
|
|
101
|
+
rawPath = '/api';
|
|
102
|
+
}
|
|
103
|
+
else if (apiIdx >= 0) {
|
|
104
|
+
rawPath = rawPath.slice(apiIdx);
|
|
105
|
+
}
|
|
106
|
+
const path = rawPath.replace(/\/+$/, '') || '/';
|
|
107
|
+
const q = url.searchParams;
|
|
108
|
+
await route(req, res, service, path, q, opts);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
sendError(res, e);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function route(req, res, service, path, q, opts) {
|
|
115
|
+
// 项目上下文权威解析:host 端会话 header.cwd 优先(对齐 ssh-tunnel getProjectContext 语义)
|
|
116
|
+
if (path === '/api/project-context' && req.method === 'POST') {
|
|
117
|
+
const b = await readBody(req);
|
|
118
|
+
const sessionId = str(b['sessionId'] ?? b['session_id']);
|
|
119
|
+
const fallbackCwd = str(b['cwd'] ?? b['projectPath'] ?? '');
|
|
120
|
+
if (!opts?.resolveProject) {
|
|
121
|
+
// 未提供解析器(如独立测试挂载):仅归一化回退值
|
|
122
|
+
const key = fallbackCwd ? service.projectKey(fallbackCwd) : '';
|
|
123
|
+
return sendOk(res, { sessionId, projectPathKey: key, hasProject: key !== '' });
|
|
124
|
+
}
|
|
125
|
+
return sendOk(res, { sessionId, ...opts.resolveProject(sessionId, fallbackCwd) });
|
|
126
|
+
}
|
|
127
|
+
if (path === '/api/connections') {
|
|
128
|
+
if (req.method === 'GET')
|
|
129
|
+
return sendOk(res, service.listConnections());
|
|
130
|
+
if (req.method === 'POST') {
|
|
131
|
+
const b = await readBody(req);
|
|
132
|
+
// id 留空自动生成(前端提示「留空则自动生成」)
|
|
133
|
+
const id = str(b['id']) || `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
134
|
+
const kind = str(b['kind']);
|
|
135
|
+
if (!kind)
|
|
136
|
+
return send(res, 400, { ok: false, error: '缺少 kind', code: 'INVALID_ARGUMENT' });
|
|
137
|
+
const ssl = parseSsl(b['ssl']);
|
|
138
|
+
if (ssl === 'invalid')
|
|
139
|
+
return send(res, 400, { ok: false, error: 'ssl 必须是布尔值', code: 'INVALID_ARGUMENT' });
|
|
140
|
+
return sendOk(res, service.createConnection({
|
|
141
|
+
id, kind: kind,
|
|
142
|
+
...(b['name'] !== undefined ? { name: str(b['name']) } : {}),
|
|
143
|
+
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
144
|
+
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
145
|
+
...(ssl !== undefined ? { ssl } : {}),
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// 测试未保存的连接草稿(保存前测试;不落库)
|
|
150
|
+
if (path === '/api/test-draft' && req.method === 'POST') {
|
|
151
|
+
const b = await readBody(req);
|
|
152
|
+
const kind = str(b['kind']);
|
|
153
|
+
if (!kind)
|
|
154
|
+
return send(res, 400, { ok: false, error: '缺少 kind', code: 'INVALID_ARGUMENT' });
|
|
155
|
+
const ssl = parseSsl(b['ssl']);
|
|
156
|
+
if (ssl === 'invalid')
|
|
157
|
+
return send(res, 400, { ok: false, error: 'ssl 必须是布尔值', code: 'INVALID_ARGUMENT' });
|
|
158
|
+
return sendMaybeConfirm(res, service.testDraft({
|
|
159
|
+
kind: kind,
|
|
160
|
+
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
161
|
+
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
162
|
+
...(ssl !== undefined ? { ssl } : {}),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
const connMatch = /^\/api\/connections\/([^/]+)(\/test)?$/.exec(path);
|
|
166
|
+
if (connMatch) {
|
|
167
|
+
const id = decodeURIComponent(connMatch[1] ?? '');
|
|
168
|
+
if (connMatch[2] === '/test' && req.method === 'POST') {
|
|
169
|
+
return sendMaybeConfirm(res, service.testConnection(id));
|
|
170
|
+
}
|
|
171
|
+
if (req.method === 'PUT') {
|
|
172
|
+
const b = await readBody(req);
|
|
173
|
+
const ssl = parseSsl(b['ssl']);
|
|
174
|
+
if (ssl === 'invalid')
|
|
175
|
+
return send(res, 400, { ok: false, error: 'ssl 必须是布尔值', code: 'INVALID_ARGUMENT' });
|
|
176
|
+
return sendOk(res, service.updateConnection(id, {
|
|
177
|
+
...(b['name'] !== undefined ? { name: str(b['name']) } : {}),
|
|
178
|
+
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
179
|
+
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
180
|
+
...(ssl !== undefined ? { ssl } : {}),
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
if (req.method === 'DELETE') {
|
|
184
|
+
service.removeConnection(id);
|
|
185
|
+
return sendOk(res, { removed: id });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (path === '/api/grants') {
|
|
189
|
+
if (req.method === 'GET') {
|
|
190
|
+
const project = q.get('project') ?? '';
|
|
191
|
+
return sendOk(res, service.grantsFor(project));
|
|
192
|
+
}
|
|
193
|
+
if (req.method === 'PUT') {
|
|
194
|
+
const b = await readBody(req);
|
|
195
|
+
const mode = str(b['mode']);
|
|
196
|
+
if (mode !== 'ro' && mode !== 'rw')
|
|
197
|
+
return send(res, 400, { ok: false, error: 'mode 必须是 ro 或 rw', code: 'INVALID_ARGUMENT' });
|
|
198
|
+
service.grant(str(b['projectPath'] ?? b['project'] ?? ''), str(b['connId']), mode);
|
|
199
|
+
return sendOk(res, { granted: true });
|
|
200
|
+
}
|
|
201
|
+
if (req.method === 'DELETE') {
|
|
202
|
+
const b = await readBody(req);
|
|
203
|
+
service.revokeGrant(str(b['projectPath'] ?? b['project'] ?? ''), str(b['connId']));
|
|
204
|
+
return sendOk(res, { revoked: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (path === '/api/databases' && req.method === 'GET') {
|
|
208
|
+
return sendOk(res, await service.databases(projectOf(q), required(q, 'connId')));
|
|
209
|
+
}
|
|
210
|
+
if (path === '/api/tables' && req.method === 'GET') {
|
|
211
|
+
return sendOk(res, await service.tables(projectOf(q), required(q, 'connId'), q.get('database') ?? undefined));
|
|
212
|
+
}
|
|
213
|
+
if (path === '/api/schema' && req.method === 'GET') {
|
|
214
|
+
return sendOk(res, await service.schema(projectOf(q), required(q, 'connId'), required(q, 'table'), q.get('database') ?? undefined));
|
|
215
|
+
}
|
|
216
|
+
if (path === '/api/preview' && req.method === 'GET') {
|
|
217
|
+
const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
|
|
218
|
+
return sendOk(res, await service.preview(projectOf(q), required(q, 'connId'), required(q, 'table'), limit, q.get('database') ?? undefined, q.get('offset') ? Number(q.get('offset')) : undefined));
|
|
219
|
+
}
|
|
220
|
+
if (path === '/api/query' && req.method === 'POST') {
|
|
221
|
+
const b = await readBody(req);
|
|
222
|
+
return await sendMaybeConfirm(res, service.query(projectOfBody(b), str(b['connId']), str(b['sql']), b['params'], optStr(b['challengeId'])));
|
|
223
|
+
}
|
|
224
|
+
if (path === '/api/execute' && req.method === 'POST') {
|
|
225
|
+
const b = await readBody(req);
|
|
226
|
+
return await sendMaybeConfirm(res, service.execute(projectOfBody(b), str(b['connId']), str(b['statement']), b['params'], optStr(b['challengeId'])));
|
|
227
|
+
}
|
|
228
|
+
if (path === '/api/script' && req.method === 'POST') {
|
|
229
|
+
const b = await readBody(req);
|
|
230
|
+
return await sendMaybeConfirm(res, service.runScript(projectOfBody(b), str(b['connId']), str(b['code']), optStr(b['challengeId'])));
|
|
231
|
+
}
|
|
232
|
+
if (path === '/api/audit' && req.method === 'GET') {
|
|
233
|
+
const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
|
|
234
|
+
return sendOk(res, service.auditTail(q.get('project') ?? undefined, limit));
|
|
235
|
+
}
|
|
236
|
+
if (path === '/api/state' && req.method === 'GET') {
|
|
237
|
+
return sendOk(res, service.state(q.get('project') ?? undefined));
|
|
238
|
+
}
|
|
239
|
+
send(res, 404, { ok: false, error: `未知路由: ${req.method} ${path}`, code: 'NOT_FOUND' });
|
|
240
|
+
}
|
|
241
|
+
export class DbToolHttpServer {
|
|
242
|
+
service;
|
|
243
|
+
opts;
|
|
244
|
+
server = null;
|
|
245
|
+
actualPort = 0;
|
|
246
|
+
actualHost = '';
|
|
247
|
+
constructor(service, opts) {
|
|
248
|
+
this.service = service;
|
|
249
|
+
this.opts = opts;
|
|
250
|
+
}
|
|
251
|
+
/** 启动并解析端口;返回实际监听地址 */
|
|
252
|
+
start() {
|
|
253
|
+
if (this.server)
|
|
254
|
+
return Promise.resolve(this.address());
|
|
255
|
+
const server = http.createServer((req, res) => {
|
|
256
|
+
void handleDbToolRequest(req, res, this.service, { trustedHosts: this.opts?.trustedHosts }).catch(() => {
|
|
257
|
+
send(res, 500, { ok: false, error: 'internal error', code: 'DRIVER_ERROR' });
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
this.server = server;
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
server.once('error', reject);
|
|
263
|
+
server.listen(this.opts?.port ?? 0, this.opts?.host ?? '127.0.0.1', () => {
|
|
264
|
+
const addr = server.address();
|
|
265
|
+
if (addr && typeof addr === 'object') {
|
|
266
|
+
this.actualPort = addr.port;
|
|
267
|
+
this.actualHost = addr.address;
|
|
268
|
+
}
|
|
269
|
+
resolve(this.address());
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
address() {
|
|
274
|
+
const host = this.actualHost || this.opts?.host || '127.0.0.1';
|
|
275
|
+
const port = this.actualPort || this.opts?.port || 0;
|
|
276
|
+
return { host, port, url: `http://${host}:${port}` };
|
|
277
|
+
}
|
|
278
|
+
async stop() {
|
|
279
|
+
const server = this.server;
|
|
280
|
+
this.server = null;
|
|
281
|
+
if (!server)
|
|
282
|
+
return;
|
|
283
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/* ---------------- 辅助 ---------------- */
|
|
287
|
+
function readBody(req) {
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
const chunks = [];
|
|
290
|
+
let size = 0;
|
|
291
|
+
req.on('data', (c) => {
|
|
292
|
+
size += c.length;
|
|
293
|
+
if (size > MAX_JSON_BODY_BYTES) {
|
|
294
|
+
reject(new DbToolError('INVALID_ARGUMENT', '请求体超过 2MB 上限'));
|
|
295
|
+
req.destroy();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
chunks.push(c);
|
|
299
|
+
});
|
|
300
|
+
req.on('end', () => {
|
|
301
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
302
|
+
if (text === '')
|
|
303
|
+
return resolve({});
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(text);
|
|
306
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
307
|
+
resolve(parsed);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
reject(new DbToolError('INVALID_ARGUMENT', '请求体必须是 JSON 对象'));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
reject(new DbToolError('INVALID_ARGUMENT', '请求体不是合法 JSON'));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
req.on('error', reject);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
function send(res, status, body) {
|
|
321
|
+
if (!res.headersSent)
|
|
322
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
323
|
+
res.end(JSON.stringify(body));
|
|
324
|
+
}
|
|
325
|
+
function sendOk(res, data) {
|
|
326
|
+
send(res, 200, { ok: true, data });
|
|
327
|
+
}
|
|
328
|
+
/** service 结果可能是值或 NeedConfirm(script 则以 NEEDS_CONFIRMATION DbToolError 抛出) */
|
|
329
|
+
async function sendMaybeConfirm(res, p) {
|
|
330
|
+
try {
|
|
331
|
+
const r = await p;
|
|
332
|
+
if (isNeedConfirm(r)) {
|
|
333
|
+
send(res, 200, { ok: false, code: 'NEEDS_CONFIRMATION', ...r });
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
sendOk(res, r);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
sendError(res, e);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function sendError(res, e) {
|
|
344
|
+
// 脚本内危险语句以 DbToolError('NEEDS_CONFIRMATION', JSON) 抛出 → 还原为扁平响应
|
|
345
|
+
if (e instanceof DbToolError && e.code === 'NEEDS_CONFIRMATION') {
|
|
346
|
+
try {
|
|
347
|
+
const nc = JSON.parse(e.message);
|
|
348
|
+
if (isNeedConfirm(nc)) {
|
|
349
|
+
send(res, 200, { ok: false, code: 'NEEDS_CONFIRMATION', ...nc });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// fallthrough
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (e instanceof DbToolError) {
|
|
358
|
+
send(res, 200, { ok: false, error: e.message, code: e.code });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
send(res, 200, { ok: false, error: e instanceof Error ? e.message : String(e), code: 'DRIVER_ERROR' });
|
|
362
|
+
}
|
|
363
|
+
function isNeedConfirm(v) {
|
|
364
|
+
return Boolean(v && typeof v === 'object' && v.needConfirmation === true);
|
|
365
|
+
}
|
|
366
|
+
function str(v) {
|
|
367
|
+
return typeof v === 'string' ? v : '';
|
|
368
|
+
}
|
|
369
|
+
/** ssl 字段严格布尔解析:防 Boolean("false") === true 的坑。非法值返回 'invalid' */
|
|
370
|
+
function parseSsl(v) {
|
|
371
|
+
if (v === undefined)
|
|
372
|
+
return undefined;
|
|
373
|
+
if (typeof v === 'boolean')
|
|
374
|
+
return v;
|
|
375
|
+
if (v === 'true')
|
|
376
|
+
return true;
|
|
377
|
+
if (v === 'false')
|
|
378
|
+
return false;
|
|
379
|
+
return 'invalid';
|
|
380
|
+
}
|
|
381
|
+
function optStr(v) {
|
|
382
|
+
return typeof v === 'string' && v !== '' ? v : undefined;
|
|
383
|
+
}
|
|
384
|
+
function projectOf(q) {
|
|
385
|
+
return q.has('project') ? (q.get('project') ?? undefined) : undefined;
|
|
386
|
+
}
|
|
387
|
+
function projectOfBody(b) {
|
|
388
|
+
const v = b['projectPath'] ?? b['project'];
|
|
389
|
+
return typeof v === 'string' ? v : undefined;
|
|
390
|
+
}
|
|
391
|
+
function required(q, name) {
|
|
392
|
+
const v = q.get(name);
|
|
393
|
+
if (!v)
|
|
394
|
+
throw new DbToolError('INVALID_ARGUMENT', `缺少查询参数: ${name}`);
|
|
395
|
+
return v;
|
|
396
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-db-tool 插件入口(形态对齐 dsh-ssh-tunnel:apply(ctx) + inject + ctx.effect)。
|
|
3
|
+
*
|
|
4
|
+
* - 注册 DatabaseManager 模型工具(单工具多 action),projectPath 取自
|
|
5
|
+
* session header.cwd(不信任模型自报),业务操作经 GrantStore 授权。
|
|
6
|
+
* - HTTP API 挂载在 DSH webServer(prefix /dsh-db-tool/api):前端同源
|
|
7
|
+
* 相对路径 fetch('/dsh-db-tool/api/...') 即可达;trust 校验移植
|
|
8
|
+
* http-trust 逻辑(Host loopback / trustedHosts + Origin 同源校验)。
|
|
9
|
+
* - ctx.effect 收尾:注销 HTTP 路由、关适配器、停 challenge 清理。
|
|
10
|
+
*/
|
|
11
|
+
import { DbToolStore, normalizeProjectKey } from './store/index.js';
|
|
12
|
+
import { DbToolService, handleToolAction } from './manager.js';
|
|
13
|
+
import { handleDbToolRequest } from './http/index.js';
|
|
14
|
+
/** cordis 注入清单(服务名以 dsh-ssh-tunnel 为准) */
|
|
15
|
+
export const inject = ['webServer', 'sessions', 'tools', 'webRuntime'];
|
|
16
|
+
const TOOL_DESCRIPTION = `数据库管理工具(dsh-db-tool):在已配置的数据库连接上执行查询/写操作/脚本。
|
|
17
|
+
|
|
18
|
+
权限模型(务必遵守):
|
|
19
|
+
- 每个连接对当前项目有 ro(只读)或 rw(读写)授权;未授权连接的一切业务操作被拒绝。
|
|
20
|
+
- ro 连接上 execute / run_script 直接被拒(READ_ONLY);适配器层另有会话级只读双保险。
|
|
21
|
+
- 危险操作(DDL、删库、FLUSHALL 等)不会直接执行:返回 NEEDS_CONFIRMATION 与 challengeId。
|
|
22
|
+
此时你必须先向用户说明语句与风险并征得明确同意,然后带着同一 challengeId 与原语句重试;
|
|
23
|
+
challenge 一次性、绑定语句、5 分钟内有效。绝不要在未获用户同意时重试。
|
|
24
|
+
|
|
25
|
+
action 说明:
|
|
26
|
+
- list_connections:列出连接(脱敏,含各连接的 kind 与授权信息需另经 HTTP 面板查看)。
|
|
27
|
+
- query:只读 SQL/命令(conn_id, sql, params?)。
|
|
28
|
+
- execute:写操作/DDL(conn_id, statement, params?)。
|
|
29
|
+
- schema:结构浏览(conn_id;给 table 查列、给 database 查表、都不给列库)。
|
|
30
|
+
- preview:预览行,limit≤50(conn_id, table, database?, limit?)。
|
|
31
|
+
- run_script:子进程沙箱脚本(conn_id, code),60s 超时强杀,permission model 禁文件
|
|
32
|
+
访问 + 新 vm realm 双层隔离,仅注入受限 db.query/db.execute 句柄(经完整
|
|
33
|
+
guard/审计链路),rw 连接才有写能力。`;
|
|
34
|
+
export function apply(ctx) {
|
|
35
|
+
const log = ctx.logger ?? { info: () => { }, error: () => { } };
|
|
36
|
+
const store = new DbToolStore();
|
|
37
|
+
const service = new DbToolService(store);
|
|
38
|
+
// HTTP API:挂在 DSH webServer 的 /dsh-db-tool/api 前缀下(同源访问)
|
|
39
|
+
const trustedHosts = () => {
|
|
40
|
+
try {
|
|
41
|
+
return ctx.webRuntime?.trustedHosts ?? [];
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
ctx.effect?.(() => ctx.webServer?.register({
|
|
48
|
+
kind: 'prefix',
|
|
49
|
+
path: '/dsh-db-tool/api',
|
|
50
|
+
handler: async (req, res) => {
|
|
51
|
+
await handleDbToolRequest(req, res, service, {
|
|
52
|
+
trustedHosts: trustedHosts(),
|
|
53
|
+
// 项目上下文权威解析:会话 header.cwd 优先,回退前端 cwd(对齐 ssh-tunnel getProjectContext)
|
|
54
|
+
resolveProject: (sessionId, fallbackCwd) => {
|
|
55
|
+
let cwd = '';
|
|
56
|
+
try {
|
|
57
|
+
cwd = sessionId !== '' ? ctx.sessions?.get?.(sessionId)?.header?.cwd ?? '' : '';
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// 会话不可得时用前端回退值
|
|
61
|
+
}
|
|
62
|
+
const key = service.projectKey(cwd || fallbackCwd || '');
|
|
63
|
+
return { projectPathKey: key, hasProject: key !== '' };
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
}) ?? (() => { }), 'db-tool-api');
|
|
68
|
+
// 收尾:适配器连接与 challenge 清理定时器
|
|
69
|
+
ctx.effect?.(() => () => service.dispose(), 'db-tool-dispose');
|
|
70
|
+
// 模型工具
|
|
71
|
+
ctx.tools?.register({
|
|
72
|
+
name: 'DatabaseManager',
|
|
73
|
+
timeoutMs: 90_000,
|
|
74
|
+
description: TOOL_DESCRIPTION,
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
action: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
enum: ['list_connections', 'query', 'execute', 'schema', 'preview', 'run_script'],
|
|
81
|
+
description: '要执行的操作',
|
|
82
|
+
},
|
|
83
|
+
conn_id: { type: 'string', description: '连接 id(query/execute/schema/preview/run_script 必填)' },
|
|
84
|
+
sql: { type: 'string', description: 'query 的只读 SQL/命令' },
|
|
85
|
+
statement: { type: 'string', description: 'execute 的语句' },
|
|
86
|
+
params: { type: 'array', items: {}, description: '占位符参数' },
|
|
87
|
+
database: { type: 'string', description: '库/schema(可选)' },
|
|
88
|
+
table: { type: 'string', description: '表/集合/键(schema/preview 用)' },
|
|
89
|
+
limit: { type: 'number', description: 'preview 行数上限(≤50)' },
|
|
90
|
+
offset: { type: 'number', description: 'preview 行偏移(翻页用,默认 0)' },
|
|
91
|
+
code: { type: 'string', description: 'run_script 的脚本源码' },
|
|
92
|
+
challenge_id: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: '危险操作确认凭据:向用户确认后原样带回重试',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ['action'],
|
|
98
|
+
},
|
|
99
|
+
output: {
|
|
100
|
+
schema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
text: { type: 'string', description: 'JSON 结果文本' },
|
|
104
|
+
isError: { type: 'boolean', description: 'true 表示操作被拒或失败' },
|
|
105
|
+
},
|
|
106
|
+
required: ['text'],
|
|
107
|
+
},
|
|
108
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
109
|
+
},
|
|
110
|
+
execute: async (args, exec) => {
|
|
111
|
+
// projectPath 从 session header.cwd 取,不信任模型自报
|
|
112
|
+
let projectPath = '';
|
|
113
|
+
try {
|
|
114
|
+
const sid = exec.agent?.session?.id ?? '';
|
|
115
|
+
const cwd = sid !== '' ? ctx.sessions?.get(sid)?.header?.cwd : undefined;
|
|
116
|
+
if (cwd)
|
|
117
|
+
projectPath = normalizeProjectKey(cwd);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// 取不到 cwd 视为匿名项目(业务操作会被拒,连接管理仍可用)
|
|
121
|
+
}
|
|
122
|
+
return handleToolAction(service, args, projectPath);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
log.info?.('[db-tool] 插件已加载(DatabaseManager 工具 + /dsh-db-tool/api 挂载)');
|
|
126
|
+
}
|
|
127
|
+
// 注意:不要提供 default export。
|
|
128
|
+
// cordis-plugin-loader 的 unwrapExports() 会优先取 exports.default,
|
|
129
|
+
// 裸函数形态会丢失 named export 的 inject 声明,导致
|
|
130
|
+
// "cannot get property "webServer" without inject" 且插件无法激活。
|