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,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run_script 子进程 worker(纯 JS,CommonJS,父进程 fork 直接加载)。
|
|
3
|
+
*
|
|
4
|
+
* 安全模型:
|
|
5
|
+
* - 脚本运行在 worker 进程的 vm.runInNewContext 新 realm 中,仅注入 db/console;
|
|
6
|
+
* - worker 进程以 Node permission model 启动(无文件系统权限),即使脚本逃逸
|
|
7
|
+
* vm realm,也读不到 secrets.json 等宿主文件;
|
|
8
|
+
* - db 调用经 IPC 回父进程,走完整 guard/challenge/审计链路;
|
|
9
|
+
* - 超时由父进程 SIGKILL 强杀(vm timeout 管不到异步,进程级才是真强杀)。
|
|
10
|
+
*
|
|
11
|
+
* IPC 协议:
|
|
12
|
+
* 父→worker {type:'run', code}
|
|
13
|
+
* worker→父 {type:'db', reqId, method, args}
|
|
14
|
+
* 父→worker {type:'dbResult', reqId, ok, result, error}
|
|
15
|
+
* worker→父 {type:'done', ok, result, error}
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const vm = require('node:vm');
|
|
20
|
+
|
|
21
|
+
process.on('message', (msg) => {
|
|
22
|
+
if (!msg || msg.type !== 'run') return;
|
|
23
|
+
runScript(String(msg.code ?? ''));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function runScript(code) {
|
|
27
|
+
/** db 请求队列:reqId → resolve */
|
|
28
|
+
const pending = new Map();
|
|
29
|
+
let reqSeq = 0;
|
|
30
|
+
|
|
31
|
+
const dbCall = (method) => (statement, params) =>
|
|
32
|
+
new Promise((resolve, reject) => {
|
|
33
|
+
const reqId = ++reqSeq;
|
|
34
|
+
pending.set(reqId, { resolve, reject });
|
|
35
|
+
process.send({ type: 'db', reqId, method, args: [statement, params] });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const db = {
|
|
39
|
+
query: dbCall('query'),
|
|
40
|
+
execute: dbCall('execute'),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const consoleBridge = {
|
|
44
|
+
log: (...a) => process.send({ type: 'log', level: 'log', text: a.map(fmt).join(' ') }),
|
|
45
|
+
error: (...a) => process.send({ type: 'log', level: 'error', text: a.map(fmt).join(' ') }),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function fmt(v) {
|
|
49
|
+
if (typeof v === 'string') return v;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.stringify(v);
|
|
52
|
+
} catch {
|
|
53
|
+
return String(v);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 父进程回包分发
|
|
58
|
+
const onMessage = (msg) => {
|
|
59
|
+
if (!msg) return;
|
|
60
|
+
if (msg.type === 'dbResult') {
|
|
61
|
+
const p = pending.get(msg.reqId);
|
|
62
|
+
if (!p) return;
|
|
63
|
+
pending.delete(msg.reqId);
|
|
64
|
+
if (msg.ok) p.resolve(msg.result);
|
|
65
|
+
else {
|
|
66
|
+
const e = new Error(msg.error?.message ?? 'db 调用失败');
|
|
67
|
+
if (msg.error?.code) e.code = msg.error.code;
|
|
68
|
+
if (msg.error?.payload !== undefined) e.payload = msg.error.payload;
|
|
69
|
+
p.reject(e);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
process.on('message', onMessage);
|
|
74
|
+
|
|
75
|
+
let sandbox;
|
|
76
|
+
try {
|
|
77
|
+
sandbox = vm.createContext({ db, console: consoleBridge });
|
|
78
|
+
const script = new vm.Script(`(async () => {\n${code}\n})()`, { filename: 'db-script.js' });
|
|
79
|
+
const result = await script.runInContext(sandbox, { timeout: 60000 });
|
|
80
|
+
process.send({ type: 'done', ok: true, result: safeClone(result) });
|
|
81
|
+
} catch (e) {
|
|
82
|
+
process.send({
|
|
83
|
+
type: 'done',
|
|
84
|
+
ok: false,
|
|
85
|
+
error: {
|
|
86
|
+
name: e instanceof Error ? e.name : 'Error',
|
|
87
|
+
message: e instanceof Error ? e.message : String(e),
|
|
88
|
+
code: e && typeof e === 'object' && e.code !== undefined ? String(e.code) : undefined,
|
|
89
|
+
payload: e && typeof e === 'object' && e.payload !== undefined ? safeClone(e.payload) : undefined,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
} finally {
|
|
93
|
+
process.off('message', onMessage);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function safeClone(v) {
|
|
98
|
+
if (v === undefined) return null;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(JSON.stringify(v));
|
|
101
|
+
} catch {
|
|
102
|
+
return String(v);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit.jsonl:追加式审计日志。每行一条 JSON,供侧边栏 tail 查看。
|
|
3
|
+
* 追加写天然原子(单行 < 4KB 时 POSIX/Windows 均保证不交错),
|
|
4
|
+
* tail 解析时跳过损坏行,单行损坏不影响其余历史。
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
export class AuditLog {
|
|
9
|
+
file;
|
|
10
|
+
constructor(dir) {
|
|
11
|
+
this.file = path.join(dir, 'audit.jsonl');
|
|
12
|
+
}
|
|
13
|
+
/** 追加一条审计;ts 省略时自动填充当前 UTC 时间 */
|
|
14
|
+
append(entry) {
|
|
15
|
+
const full = { ...entry, ts: entry.ts ?? new Date().toISOString() };
|
|
16
|
+
fs.appendFileSync(this.file, JSON.stringify(full) + '\n', 'utf8');
|
|
17
|
+
return full;
|
|
18
|
+
}
|
|
19
|
+
/** 最近 n 条(时间正序)。文件不存在返回空数组;损坏行跳过。
|
|
20
|
+
* 设计取舍:tail 全量读入后取尾。审计日志不轮转(保留完整历史是审计语义),
|
|
21
|
+
* 单用户桌面工具的量级(数十万行 ≈ 数十 MB)一次性读取在可接受范围;
|
|
22
|
+
* 反向块读的复杂度不值得。若未来出现服务端长驻场景再优化。 */
|
|
23
|
+
tail(n) {
|
|
24
|
+
if (!fs.existsSync(this.file))
|
|
25
|
+
return [];
|
|
26
|
+
const lines = fs.readFileSync(this.file, 'utf8').split('\n');
|
|
27
|
+
const out = [];
|
|
28
|
+
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
|
|
29
|
+
const line = lines[i]?.trim();
|
|
30
|
+
if (!line)
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
out.push(JSON.parse(line));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// 跳过损坏行
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out.reverse();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connections.json:连接元数据(绝不含密码)。
|
|
3
|
+
*
|
|
4
|
+
* create/update 时自动做机密拆分:
|
|
5
|
+
* - url → 完整 URL 进 secrets.json,脱敏副本(密码段 ***)留在本文件 urlSafe;
|
|
6
|
+
* - fields.password → 进 secrets.json,本文件的 fields 不再含有。
|
|
7
|
+
* remove 级联清理 secrets 与该连接的全部项目授权。
|
|
8
|
+
*/
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { readJson, writeJsonAtomic } from './io.js';
|
|
11
|
+
/** fields 中被视为机密的键,落盘前拆出到 secrets.json */
|
|
12
|
+
const PASSWORD_KEY = 'password';
|
|
13
|
+
/** 将 URL 中的密码段替换为 ***:scheme://user:pass@host → scheme://user:***@host。
|
|
14
|
+
* 密码可能包含 / ? # @ 等字符:先取 scheme:// 到首个 /?# 的 authority 段,
|
|
15
|
+
* 在 authority 内以最后一个 @ 分界取 userinfo,再以最后一个 : 分界取密码。 */
|
|
16
|
+
export function redactUrl(url) {
|
|
17
|
+
const m = url.match(/^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*)/i);
|
|
18
|
+
if (!m)
|
|
19
|
+
return url;
|
|
20
|
+
const authority = m[2] ?? '';
|
|
21
|
+
const at = authority.lastIndexOf('@');
|
|
22
|
+
if (at < 0)
|
|
23
|
+
return url; // 无 userinfo
|
|
24
|
+
const userinfo = authority.slice(0, at);
|
|
25
|
+
const colon = userinfo.lastIndexOf(':');
|
|
26
|
+
if (colon < 0)
|
|
27
|
+
return url; // 只有用户名,无密码段
|
|
28
|
+
const redacted = m[1] + userinfo.slice(0, colon + 1) + '***@' + authority.slice(at + 1);
|
|
29
|
+
return redacted + url.slice(m[0].length);
|
|
30
|
+
}
|
|
31
|
+
function splitPassword(fields) {
|
|
32
|
+
if (!fields || typeof fields[PASSWORD_KEY] !== 'string') {
|
|
33
|
+
return { clean: fields, password: undefined };
|
|
34
|
+
}
|
|
35
|
+
const { [PASSWORD_KEY]: password, ...clean } = fields;
|
|
36
|
+
return { clean, password: password };
|
|
37
|
+
}
|
|
38
|
+
/** ConnRecord → 用户可见的 ConnectionMeta(契约见 lib/adapters/types.ts) */
|
|
39
|
+
function toMeta(rec) {
|
|
40
|
+
const meta = { id: rec.id, kind: rec.kind };
|
|
41
|
+
if (rec.name !== undefined)
|
|
42
|
+
meta.name = rec.name;
|
|
43
|
+
if (rec.urlSafe !== undefined)
|
|
44
|
+
meta.safeUrl = rec.urlSafe;
|
|
45
|
+
if (rec.fields) {
|
|
46
|
+
const { host, port, database } = rec.fields;
|
|
47
|
+
if (typeof host === 'string')
|
|
48
|
+
meta.host = host;
|
|
49
|
+
if (typeof port === 'number')
|
|
50
|
+
meta.port = port;
|
|
51
|
+
if (typeof database === 'string')
|
|
52
|
+
meta.database = database;
|
|
53
|
+
}
|
|
54
|
+
return meta;
|
|
55
|
+
}
|
|
56
|
+
export class ConnectionStore {
|
|
57
|
+
secrets;
|
|
58
|
+
grants;
|
|
59
|
+
file;
|
|
60
|
+
constructor(dir, secrets, grants) {
|
|
61
|
+
this.secrets = secrets;
|
|
62
|
+
this.grants = grants;
|
|
63
|
+
this.file = path.join(dir, 'connections.json');
|
|
64
|
+
}
|
|
65
|
+
load() {
|
|
66
|
+
return readJson(this.file, { connections: [] });
|
|
67
|
+
}
|
|
68
|
+
save(data) {
|
|
69
|
+
writeJsonAtomic(this.file, data);
|
|
70
|
+
}
|
|
71
|
+
findRec(data, id) {
|
|
72
|
+
return data.connections.find((c) => c.id === id);
|
|
73
|
+
}
|
|
74
|
+
list() {
|
|
75
|
+
return this.load().connections.map(toMeta);
|
|
76
|
+
}
|
|
77
|
+
get(id) {
|
|
78
|
+
const rec = this.findRec(this.load(), id);
|
|
79
|
+
return rec ? toMeta(rec) : undefined;
|
|
80
|
+
}
|
|
81
|
+
/** 新建连接;id 已存在时抛错 */
|
|
82
|
+
create(input) {
|
|
83
|
+
const data = this.load();
|
|
84
|
+
if (this.findRec(data, input.id)) {
|
|
85
|
+
throw new Error(`连接已存在: ${input.id}`);
|
|
86
|
+
}
|
|
87
|
+
const rec = { id: input.id, kind: input.kind };
|
|
88
|
+
if (input.name !== undefined)
|
|
89
|
+
rec.name = input.name;
|
|
90
|
+
if (input.ssl !== undefined)
|
|
91
|
+
rec.ssl = input.ssl;
|
|
92
|
+
const { clean, password } = splitPassword(input.fields);
|
|
93
|
+
if (clean !== undefined)
|
|
94
|
+
rec.fields = clean;
|
|
95
|
+
const secretPatch = {};
|
|
96
|
+
if (input.url !== undefined) {
|
|
97
|
+
rec.urlSafe = redactUrl(input.url);
|
|
98
|
+
secretPatch.url = input.url;
|
|
99
|
+
}
|
|
100
|
+
if (password !== undefined)
|
|
101
|
+
secretPatch.password = password;
|
|
102
|
+
if (Object.keys(secretPatch).length > 0)
|
|
103
|
+
this.secrets.set(input.id, secretPatch);
|
|
104
|
+
data.connections.push(rec);
|
|
105
|
+
this.save(data);
|
|
106
|
+
return toMeta(rec);
|
|
107
|
+
}
|
|
108
|
+
/** 部分更新;不存在返回 undefined。url/fields 变更时同步拆分 secrets */
|
|
109
|
+
update(id, patch) {
|
|
110
|
+
const data = this.load();
|
|
111
|
+
const rec = this.findRec(data, id);
|
|
112
|
+
if (!rec)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (patch.name !== undefined)
|
|
115
|
+
rec.name = patch.name;
|
|
116
|
+
if (patch.ssl !== undefined)
|
|
117
|
+
rec.ssl = patch.ssl;
|
|
118
|
+
if (patch.fields !== undefined) {
|
|
119
|
+
const { clean, password } = splitPassword(patch.fields);
|
|
120
|
+
rec.fields = clean;
|
|
121
|
+
if (password !== undefined)
|
|
122
|
+
this.secrets.set(id, { password });
|
|
123
|
+
}
|
|
124
|
+
if (patch.url !== undefined) {
|
|
125
|
+
rec.urlSafe = redactUrl(patch.url);
|
|
126
|
+
this.secrets.set(id, { url: patch.url });
|
|
127
|
+
}
|
|
128
|
+
this.save(data);
|
|
129
|
+
return toMeta(rec);
|
|
130
|
+
}
|
|
131
|
+
/** 删除连接,级联删除 secrets 与该连接的所有项目授权。
|
|
132
|
+
* 顺序按"失败开放"原则:先删权限(grants),再删机密(secrets),最后改
|
|
133
|
+
* 连接表——中途崩溃最多残留垃圾机密,绝不残留可用授权。 */
|
|
134
|
+
remove(id) {
|
|
135
|
+
const data = this.load();
|
|
136
|
+
const idx = data.connections.findIndex((c) => c.id === id);
|
|
137
|
+
if (idx < 0)
|
|
138
|
+
return false;
|
|
139
|
+
this.grants.removeConn(id);
|
|
140
|
+
this.secrets.delete(id);
|
|
141
|
+
data.connections.splice(idx, 1);
|
|
142
|
+
this.save(data);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 组装适配器工厂所需的 ResolvedConnection(含真实密码,绝不返回给模型/HTTP)。
|
|
147
|
+
* 不存在抛错——调用方(工具服务层)应先 get() 展示层校验。
|
|
148
|
+
*/
|
|
149
|
+
testTarget(id) {
|
|
150
|
+
const rec = this.findRec(this.load(), id);
|
|
151
|
+
if (!rec)
|
|
152
|
+
throw new Error(`连接不存在: ${id}`);
|
|
153
|
+
const meta = toMeta(rec);
|
|
154
|
+
const rc = { meta };
|
|
155
|
+
const sec = this.secrets.get(id);
|
|
156
|
+
if (sec?.url !== undefined)
|
|
157
|
+
rc.url = sec.url;
|
|
158
|
+
let fields = rec.fields ? { ...rec.fields } : undefined;
|
|
159
|
+
if (sec?.password !== undefined)
|
|
160
|
+
(fields ??= {}).password = sec.password;
|
|
161
|
+
if (fields !== undefined)
|
|
162
|
+
rc.fields = fields;
|
|
163
|
+
if (rec.ssl !== undefined)
|
|
164
|
+
rc.ssl = rec.ssl;
|
|
165
|
+
return rc;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grants.json:项目级连接授权(ro/rw)。
|
|
3
|
+
* 键是 normalizeProjectKey 归一化后的项目路径。
|
|
4
|
+
*/
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { readJson, writeJsonAtomic } from './io.js';
|
|
7
|
+
export class GrantStore {
|
|
8
|
+
file;
|
|
9
|
+
constructor(dir) {
|
|
10
|
+
this.file = path.join(dir, 'grants.json');
|
|
11
|
+
}
|
|
12
|
+
load() {
|
|
13
|
+
return readJson(this.file, { grants: {} });
|
|
14
|
+
}
|
|
15
|
+
save(data) {
|
|
16
|
+
// 0600 对齐 dsh-ssh-tunnel:授权关系本身也是敏感面(泄露项目↔库映射)
|
|
17
|
+
writeJsonAtomic(this.file, data, 0o600);
|
|
18
|
+
}
|
|
19
|
+
/** 某项目的全部授权(不含 grantedAt,展示层无需时间戳) */
|
|
20
|
+
grantsFor(projectKey) {
|
|
21
|
+
return (this.load().grants[projectKey] ?? []).map(({ connId, mode }) => ({ connId, mode }));
|
|
22
|
+
}
|
|
23
|
+
/** 授权或改授权(同连接重复授权视为更新模式) */
|
|
24
|
+
grant(projectKey, connId, mode) {
|
|
25
|
+
const data = this.load();
|
|
26
|
+
const list = data.grants[projectKey] ?? [];
|
|
27
|
+
const existing = list.find((g) => g.connId === connId);
|
|
28
|
+
if (existing) {
|
|
29
|
+
existing.mode = mode;
|
|
30
|
+
existing.grantedAt = new Date().toISOString();
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
list.push({ connId, mode, grantedAt: new Date().toISOString() });
|
|
34
|
+
}
|
|
35
|
+
data.grants[projectKey] = list;
|
|
36
|
+
this.save(data);
|
|
37
|
+
}
|
|
38
|
+
/** 撤销某项目对某连接的授权。不存在时静默返回 */
|
|
39
|
+
revoke(projectKey, connId) {
|
|
40
|
+
const data = this.load();
|
|
41
|
+
const list = data.grants[projectKey];
|
|
42
|
+
if (!list)
|
|
43
|
+
return;
|
|
44
|
+
const next = list.filter((g) => g.connId !== connId);
|
|
45
|
+
if (next.length === list.length)
|
|
46
|
+
return;
|
|
47
|
+
if (next.length === 0)
|
|
48
|
+
delete data.grants[projectKey];
|
|
49
|
+
else
|
|
50
|
+
data.grants[projectKey] = next;
|
|
51
|
+
this.save(data);
|
|
52
|
+
}
|
|
53
|
+
/** 检查授权模式;未授权返回 undefined(ro < rw,rw 覆盖 ro) */
|
|
54
|
+
check(projectKey, connId) {
|
|
55
|
+
const modes = (this.load().grants[projectKey] ?? [])
|
|
56
|
+
.filter((g) => g.connId === connId)
|
|
57
|
+
.map((g) => g.mode);
|
|
58
|
+
if (modes.includes('rw'))
|
|
59
|
+
return 'rw';
|
|
60
|
+
return modes.includes('ro') ? 'ro' : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** 级联清理:删除某连接在全项目范围的授权(由 ConnectionStore.remove 调用) */
|
|
63
|
+
removeConn(connId) {
|
|
64
|
+
const data = this.load();
|
|
65
|
+
let dirty = false;
|
|
66
|
+
for (const key of Object.keys(data.grants)) {
|
|
67
|
+
const list = data.grants[key];
|
|
68
|
+
if (!list)
|
|
69
|
+
continue;
|
|
70
|
+
const next = list.filter((g) => g.connId !== connId);
|
|
71
|
+
if (next.length !== list.length) {
|
|
72
|
+
dirty = true;
|
|
73
|
+
if (next.length === 0)
|
|
74
|
+
delete data.grants[key];
|
|
75
|
+
else
|
|
76
|
+
data.grants[key] = next;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (dirty)
|
|
80
|
+
this.save(data);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-db-tool 存储层顶层入口。
|
|
3
|
+
*
|
|
4
|
+
* 四类持久化数据位于 `$DSH_HOME/db-tool/`(DSH_HOME 环境变量,缺省 ~/.dsh):
|
|
5
|
+
* - connections.json 连接元数据(不含密码,权限跟随目录 0700)
|
|
6
|
+
* - secrets.json 机密(密码/完整 URL,权限 0600)
|
|
7
|
+
* - grants.json 项目级连接授权
|
|
8
|
+
* - audit.jsonl 追加式审计日志
|
|
9
|
+
*
|
|
10
|
+
* 构造参数 homeDir 仅供测试注入临时目录。
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { AuditLog } from './audit.js';
|
|
16
|
+
import { ConnectionStore } from './connections.js';
|
|
17
|
+
import { GrantStore } from './grants.js';
|
|
18
|
+
import { chmodBestEffort } from './io.js';
|
|
19
|
+
import { SecretsBox } from './secrets.js';
|
|
20
|
+
export { normalizeProjectKey } from './normalize.js';
|
|
21
|
+
export { redactUrl } from './connections.js';
|
|
22
|
+
export { AuditLog, ConnectionStore, GrantStore, SecretsBox };
|
|
23
|
+
export class DbToolStore {
|
|
24
|
+
/** 数据目录:$DSH_HOME/db-tool */
|
|
25
|
+
dir;
|
|
26
|
+
connections;
|
|
27
|
+
secrets;
|
|
28
|
+
grants;
|
|
29
|
+
audit;
|
|
30
|
+
constructor(homeDir) {
|
|
31
|
+
// || 而非 ??: DSH_HOME=""(空串)视为未设置,避免产出相对路径 'db-tool/'
|
|
32
|
+
const home = homeDir ?? (process.env['DSH_HOME'] || path.join(os.homedir(), '.dsh'));
|
|
33
|
+
this.dir = path.join(home, 'db-tool');
|
|
34
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
35
|
+
chmodBestEffort(this.dir, 0o700);
|
|
36
|
+
this.secrets = new SecretsBox(this.dir);
|
|
37
|
+
this.grants = new GrantStore(this.dir);
|
|
38
|
+
this.audit = new AuditLog(this.dir);
|
|
39
|
+
this.connections = new ConnectionStore(this.dir, this.secrets, this.grants);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/store/io.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 存储层文件 IO 基础设施:原子写 + 损坏容错读 + 尽力而为的权限设置。
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* 尽力而为地设置 POSIX 权限。
|
|
8
|
+
* Windows 的 chmod 仅支持只读位,失败时静默忽略,绝不崩溃。
|
|
9
|
+
*/
|
|
10
|
+
export function chmodBestEffort(target, mode) {
|
|
11
|
+
try {
|
|
12
|
+
fs.chmodSync(target, mode);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// 权限设置失败(Windows 等)不影响功能
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 原子写 JSON:先写同目录临时文件,再 rename 覆盖目标。
|
|
20
|
+
* rename 在同一文件系统内是原子操作,进程崩溃也不会留下半个 JSON。
|
|
21
|
+
* fileMode 提供时写完设置文件权限(secrets.json 传 0o600)。
|
|
22
|
+
*/
|
|
23
|
+
export function writeJsonAtomic(file, value, fileMode) {
|
|
24
|
+
const dir = path.dirname(file);
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
27
|
+
// tmp 以目标权限创建:机密文件(0600)在 rename 前的窗口期/失败残留时不暴露宽权限内容
|
|
28
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf8', mode: fileMode ?? 0o666 });
|
|
29
|
+
try {
|
|
30
|
+
fs.renameSync(tmp, file);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
try {
|
|
34
|
+
fs.rmSync(tmp, { force: true });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// 清理失败不掩盖原始错误
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
if (fileMode !== undefined)
|
|
42
|
+
chmodBestEffort(file, fileMode);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 容错读 JSON:
|
|
46
|
+
* - 文件不存在 → 返回 fallback(不创建文件,首次写时才落盘);
|
|
47
|
+
* - 解析失败(文件损坏/被截断)→ 将原文件备份为 `<file>.bak` 后返回 fallback,
|
|
48
|
+
* 让调用方以空数据继续工作,损坏现场保留在 .bak 供人工恢复。
|
|
49
|
+
*/
|
|
50
|
+
export function readJson(file, fallback) {
|
|
51
|
+
if (!fs.existsSync(file))
|
|
52
|
+
return fallback;
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
try {
|
|
58
|
+
fs.rmSync(`${file}.bak`, { force: true });
|
|
59
|
+
fs.renameSync(file, `${file}.bak`);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// 备份失败也要继续重建
|
|
63
|
+
}
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectPathKey 规范化。
|
|
3
|
+
*
|
|
4
|
+
* 授权记录以项目路径为键,同一项目可能以不同写法出现:
|
|
5
|
+
* 大小写不同的盘符、正/反斜杠、尾分隔符、相对路径。
|
|
6
|
+
* 所有入口(授权、检查、审计)必须先经本函数归一化,
|
|
7
|
+
* 否则同一项目会产生多条授权记录,ro/rw 检查会漏判。
|
|
8
|
+
*
|
|
9
|
+
* 规则(按序):
|
|
10
|
+
* 1. path.resolve → 转为绝对路径(相对路径基于 cwd 解析)
|
|
11
|
+
* 2. 分隔符统一为 '/'
|
|
12
|
+
* 3. Windows 盘符小写('C:/' 与 'c:/' 是同一目录)
|
|
13
|
+
* 4. 去尾分隔符('c:/code/proj/' → 'c:/code/proj')
|
|
14
|
+
*/
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
export function normalizeProjectKey(p) {
|
|
17
|
+
let key = path.resolve(p).replace(/\\/g, '/');
|
|
18
|
+
key = key.replace(/^([A-Z]):/, (_m, drive) => `${drive.toLowerCase()}:`);
|
|
19
|
+
if (key.length > 1)
|
|
20
|
+
key = key.replace(/\/+$/, '');
|
|
21
|
+
return key;
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secrets.json:连接级机密存储(密码、完整 URL、其余敏感键值)。
|
|
3
|
+
*
|
|
4
|
+
* 硬性约束:
|
|
5
|
+
* - 文件落盘后设置权限 0600(Windows 上尽力而为,失败不崩溃);
|
|
6
|
+
* - 本类的任何返回值都是浅拷贝,外部改动不污染内部状态;
|
|
7
|
+
* - 机密绝不进入 connections.json / grants.json / 审计等其它文件。
|
|
8
|
+
*/
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { readJson, writeJsonAtomic } from './io.js';
|
|
11
|
+
const FILE_MODE = 0o600;
|
|
12
|
+
export class SecretsBox {
|
|
13
|
+
file;
|
|
14
|
+
constructor(dir) {
|
|
15
|
+
this.file = path.join(dir, 'secrets.json');
|
|
16
|
+
}
|
|
17
|
+
load() {
|
|
18
|
+
return readJson(this.file, { secrets: {} });
|
|
19
|
+
}
|
|
20
|
+
save(data) {
|
|
21
|
+
writeJsonAtomic(this.file, data, FILE_MODE);
|
|
22
|
+
}
|
|
23
|
+
/** 读取某连接的机密(浅拷贝)。不存在返回 undefined。 */
|
|
24
|
+
get(connId) {
|
|
25
|
+
const entry = this.load().secrets[connId];
|
|
26
|
+
return entry ? { ...entry } : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** 合并写入机密(保留未提及的旧键) */
|
|
29
|
+
set(connId, patch) {
|
|
30
|
+
const data = this.load();
|
|
31
|
+
data.secrets[connId] = { ...(data.secrets[connId] ?? {}), ...patch };
|
|
32
|
+
this.save(data);
|
|
33
|
+
}
|
|
34
|
+
/** 删除某连接的全部机密。不存在时静默返回。 */
|
|
35
|
+
delete(connId) {
|
|
36
|
+
const data = this.load();
|
|
37
|
+
if (!(connId in data.secrets))
|
|
38
|
+
return;
|
|
39
|
+
delete data.secrets[connId];
|
|
40
|
+
this.save(data);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# dsh-db-tool HTTP API 契约(v1)
|
|
2
|
+
|
|
3
|
+
侧边栏 client 与 lib/http 实现共同遵守。API 仅监听 loopback(127.0.0.1),校验 `Origin`/`Referer` 必须来自 DSH 本地 web(同源 fenced)。所有请求/响应 JSON。
|
|
4
|
+
|
|
5
|
+
## 通用约定
|
|
6
|
+
|
|
7
|
+
- 成功:`200 { "ok": true, "data": ... }`
|
|
8
|
+
- 失败:`{ "ok": false, "error": string, "code": string }`,code ∈ `UNAUTHORIZED_PROJECT`(项目未授权该连接)/ `READ_ONLY`(ro 连接拒绝 execute)/ `NEEDS_CONFIRMATION`(危险操作待确认)/ `INVALID_CHALLENGE` / `NOT_FOUND` / `INVALID_ARGUMENT`(参数非法;插件 dispose 后的新请求同样返回此 code,error 为「服务已关闭」)/ `DRIVER_ERROR`
|
|
9
|
+
- `projectPath`:前端传入当前 workspace 绝对路径,服务端用 `normalizeProjectKey` 归一后校验 grants;**不传视为匿名项目**(仅连接管理可用,业务操作一律拒)
|
|
10
|
+
- 人工确认通道:SQL 控制台是人工操作,危险语句同样走 challenge 流程——前端展示确认对话框后携 `challengeId` 重发
|
|
11
|
+
- 除注明外,业务端点均需 projectPath 且该连接已授权
|
|
12
|
+
|
|
13
|
+
## 路由
|
|
14
|
+
|
|
15
|
+
### 连接管理(无需 projectPath)
|
|
16
|
+
|
|
17
|
+
| 方法/路径 | 说明 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `GET /api/connections` | 全量连接列表(脱敏 ConnectionMeta[],含每库 kind) |
|
|
20
|
+
| `POST /api/connections` | 创建 `{ id, kind, name?, url?, fields?, ssl? }`;密码自动拆入 secrets |
|
|
21
|
+
| `PUT /api/connections/:id` | 更新(同上字段可选) |
|
|
22
|
+
| `DELETE /api/connections/:id` | 删除(级联 secrets + 全项目 grants) |
|
|
23
|
+
| `POST /api/connections/:id/test` | 测试连接 → `TestConnectResult` |
|
|
24
|
+
|
|
25
|
+
### 项目授权
|
|
26
|
+
|
|
27
|
+
| 方法/路径 | 说明 |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `GET /api/grants?project=<path>` | 该项目授权列表 `{ connId, mode }[]` |
|
|
30
|
+
| `PUT /api/grants` | `{ projectPath, connId, mode: "ro"\|"rw" }` 授权/改模式 |
|
|
31
|
+
| `DELETE /api/grants` | `{ projectPath, connId }` 撤销 |
|
|
32
|
+
|
|
33
|
+
### 浏览(需授权,ro 即可)
|
|
34
|
+
|
|
35
|
+
| 方法/路径 | 说明 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `GET /api/databases?project=<path>&connId=` | 库/schema 清单 string[] |
|
|
38
|
+
| `GET /api/tables?project=<path>&connId=&database=` | TableInfo[] |
|
|
39
|
+
| `GET /api/schema?project=<path>&connId=&database=&table=` | ColumnInfo[] |
|
|
40
|
+
| `GET /api/preview?project=<path>&connId=&database=&table=&limit=&offset=` | QueryResult(limit≤50 强制;offset 为行偏移,翻页用,默认 0) |
|
|
41
|
+
|
|
42
|
+
### SQL 控制台 / 脚本(需授权,execute 需 rw)
|
|
43
|
+
|
|
44
|
+
| 方法/路径 | 说明 |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `POST /api/query` | `{ projectPath, connId, sql, params?, challengeId? }` → QueryResult;危险读(如 KEYS)同样触发 NEEDS_CONFIRMATION |
|
|
47
|
+
| `POST /api/execute` | `{ projectPath, connId, statement, params?, challengeId? }` → ExecResult 或 `NEEDS_CONFIRMATION { challengeId, statement, danger, reason }` |
|
|
48
|
+
| `POST /api/script` | `{ projectPath, connId, code, challengeId? }` → 子进程沙箱执行(Node permission model 禁 fs + 新 vm realm 双层隔离),60s 强超时;db 句柄经 IPC 走完整 guard/审计链路 |
|
|
49
|
+
|
|
50
|
+
### 审计
|
|
51
|
+
|
|
52
|
+
| 方法/路径 | 说明 |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `GET /api/audit?project=<path>&limit=` | AuditEntry[](最近 n 条,默认 50) |
|
|
55
|
+
|
|
56
|
+
### 状态
|
|
57
|
+
|
|
58
|
+
| 方法/路径 | 说明 |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `GET /api/state?project=<path>` | 面板初始化:`{ connections, grants, auditTail }` |
|
|
61
|
+
|
|
62
|
+
## NEEDS_CONFIRMATION 语义
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"ok": false,
|
|
67
|
+
"code": "NEEDS_CONFIRMATION",
|
|
68
|
+
"challengeId": "c_9f3a…",
|
|
69
|
+
"statement": "DROP TABLE users",
|
|
70
|
+
"danger": "danger",
|
|
71
|
+
"reason": "DDL 不可回滚(隐式提交)"
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- `challengeId` 一次性、绑定语句 hash、5 分钟过期;重发请求带同 `challengeId` 且语句 hash 一致才放行
|
|
76
|
+
- 模型工具(对话内)用同一 guard/challenge 实现,确认经 ask_user 完成
|