dsh-plugin-remote-connect-beta 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * 多租户管理层:把「租户注册表」和「每租户一个 Harness 实例」拼在一起。
3
+ *
4
+ * 分工:
5
+ * - core/tenant.js —— 租户定义与注册表(纯数据)
6
+ * - core/instance.js —— 单个实例的启动/看护(纯进程)
7
+ * - 本模块 —— 两者之间的编排:谁该起、谁在跑、路由句柄、增删改查
8
+ * - core/proxy.js —— 按租户凭据把请求送到对应实例
9
+ *
10
+ * 路由句柄的形状(proxy 只认这三个字段):
11
+ * { id, upstreamPort(): number, token(): string }
12
+ * 实例没起来时返回 0 / '',网关会回 503("该租户的 Harness 还没起来"),而不是把人挡在门外。
13
+ *
14
+ * @module dsh-plugin-remote-connect-beta/core/tenancy
15
+ */
16
+ import path from 'node:path'
17
+ import { createRegistry, generateAccessKey, normalizeTenant, slugifyId } from './tenant.js'
18
+ import { createInstance, discoverHarnessBin, discoverRuntime } from './instance.js'
19
+ import { translator } from './messages.js'
20
+
21
+ const tEn = translator('en')
22
+
23
+ /**
24
+ * @param {object} options
25
+ * @param {object} options.config 归一化后的 `config.tenants`
26
+ * @param {(line: string) => void} [options.log]
27
+ * @param {(state: object) => void} [options.onState] 任一实例状态变化时回调(面板据此刷新)
28
+ */
29
+ export function createTenancy(options) {
30
+ const config = options.config
31
+ const log = options.log ?? (() => {})
32
+ const onState = options.onState ?? (() => {})
33
+
34
+ const registry = createRegistry({
35
+ file: config.registry,
36
+ baseDir: config.baseDir,
37
+ log,
38
+ })
39
+
40
+ const discovered = {
41
+ bin: discoverHarnessBin({ configured: config.harness.bin }),
42
+ runtime: discoverRuntime({ configured: config.harness.node }),
43
+ }
44
+
45
+ /** @type {Map<string, object>} 租户 id → 实例 */
46
+ const instances = new Map()
47
+ /** @type {Map<string, object>} 租户 id → 最近一次状态 */
48
+ const states = new Map()
49
+
50
+ function harnessProblem() {
51
+ if (discovered.bin === null) {
52
+ return '找不到 DSH Harness 入口:请在 tenants.harness.bin 指定 @deepseek-ai/dsh/lib/bin.js 的路径'
53
+ }
54
+ return null
55
+ }
56
+
57
+ function instanceFor(tenant) {
58
+ const existing = instances.get(tenant.id)
59
+ if (existing !== undefined) return existing
60
+ if (harnessProblem() !== null) return null
61
+ const instance = createInstance({
62
+ tenant,
63
+ harness: {
64
+ bin: discovered.bin.bin,
65
+ node: discovered.runtime.node,
66
+ electron: discovered.runtime.electron,
67
+ extraArgs: config.harness.extraArgs,
68
+ },
69
+ log,
70
+ onState: (state) => {
71
+ states.set(state.id, state)
72
+ onState(state)
73
+ },
74
+ })
75
+ instances.set(tenant.id, instance)
76
+ return instance
77
+ }
78
+
79
+ /** 路由句柄:租户存在就给(哪怕实例没起来,交给网关回 503)。 */
80
+ function handle(id) {
81
+ const tenant = registry.get(id)
82
+ if (tenant === null) return null
83
+ const instance = instances.get(id)
84
+ if (instance === undefined) {
85
+ return { id, tenant, upstreamPort: () => 0, token: () => '' }
86
+ }
87
+ return {
88
+ id,
89
+ tenant,
90
+ upstreamPort: () => instance.upstreamPort(),
91
+ token: () => instance.token(),
92
+ state: () => instance.state(),
93
+ }
94
+ }
95
+
96
+ /** 面板/CLI 看的一份汇总:租户定义 + 运行状态 + 就绪信息。 */
97
+ function describe(id) {
98
+ const tenant = registry.get(id)
99
+ if (tenant === null) return null
100
+ const state = states.get(id) ?? {
101
+ id,
102
+ phase: 'idle',
103
+ code: 'tenant.idle',
104
+ params: { id },
105
+ detail: tEn('tenant.idle'),
106
+ port: tenant.port,
107
+ hasToken: false,
108
+ restarts: 0,
109
+ }
110
+ return {
111
+ id: tenant.id,
112
+ name: tenant.name,
113
+ accessKey: tenant.accessKey,
114
+ home: tenant.home,
115
+ profile: tenant.profile,
116
+ port: state.port ?? tenant.port,
117
+ autostart: tenant.autostart,
118
+ enabled: tenant.enabled,
119
+ note: tenant.note,
120
+ createdAt: tenant.createdAt,
121
+ phase: state.phase,
122
+ code: state.code,
123
+ params: state.params,
124
+ detail: state.detail,
125
+ hint: state.hintCode === undefined ? undefined : tEn(state.hintCode),
126
+ hasToken: state.hasToken === true,
127
+ restarts: state.restarts ?? 0,
128
+ pid: state.pid ?? null,
129
+ running: state.phase === 'up',
130
+ }
131
+ }
132
+
133
+ function list() {
134
+ return registry.list().map((tenant) => describe(tenant.id))
135
+ }
136
+
137
+ /** 读注册表,并把组合里预置的租户补齐(组合是"种子",注册表是运行时事实来源)。 */
138
+ function load() {
139
+ const result = registry.load()
140
+ for (const seed of config.list) {
141
+ const { problems, tenant } = normalizeTenant(seed, { baseDir: config.baseDir })
142
+ if (problems.length > 0) {
143
+ log('组合里预置的租户不合法,已跳过:' + problems.join(';'))
144
+ continue
145
+ }
146
+ if (registry.get(tenant.id) !== null) continue
147
+ try {
148
+ registry.add({ ...tenant, accessKey: tenant.accessKey === '' ? generateAccessKey() : tenant.accessKey })
149
+ } catch (error) {
150
+ log('预置租户 ' + tenant.id + ' 未能写入注册表:' + String(error?.message ?? error))
151
+ }
152
+ }
153
+ return { loaded: result.loaded, dropped: result.dropped, total: registry.size() }
154
+ }
155
+
156
+ function start(id) {
157
+ const tenant = registry.get(id)
158
+ if (tenant === null) throw new Error('租户不存在:' + String(id))
159
+ if (harnessProblem() !== null) throw new Error(harnessProblem())
160
+ const instance = instanceFor(tenant)
161
+ instance.start()
162
+ return describe(id)
163
+ }
164
+
165
+ async function stop(id) {
166
+ const instance = instances.get(id)
167
+ if (instance === undefined) return describe(id)
168
+ await instance.stop()
169
+ return describe(id)
170
+ }
171
+
172
+ /** 自动拉起所有 autostart 的租户(插件装载/局域网或公网入口启动时调用)。 */
173
+ function startAutostart() {
174
+ if (!config.enabled || config.autostart !== true) return []
175
+ const started = []
176
+ for (const tenant of registry.list()) {
177
+ if (tenant.enabled !== true || tenant.autostart !== true) continue
178
+ try {
179
+ start(tenant.id)
180
+ started.push(tenant.id)
181
+ } catch (error) {
182
+ log('租户 ' + tenant.id + ' 自动启动失败:' + String(error?.message ?? error))
183
+ }
184
+ }
185
+ return started
186
+ }
187
+
188
+ async function stopAll() {
189
+ const all = [...instances.values()]
190
+ instances.clear()
191
+ await Promise.all(all.map((instance) => instance.stop().catch(() => {})))
192
+ }
193
+
194
+ /**
195
+ * 面板/CLI 新增租户:id 可由显示名自动收敛,访问密钥自动生成。
196
+ * @returns {{ tenant: object, accessKey: string }}
197
+ */
198
+ function add(input = {}) {
199
+ const requestedId = typeof input.id === 'string' && input.id.trim() !== '' ? input.id.trim() : ''
200
+ const id = requestedId !== '' ? requestedId : slugifyId(input.name ?? '', 'u')
201
+ const tenant = registry.add({
202
+ id,
203
+ name: typeof input.name === 'string' && input.name.trim() !== '' ? input.name.trim() : id,
204
+ note: typeof input.note === 'string' ? input.note : '',
205
+ accessKey: typeof input.accessKey === 'string' && input.accessKey !== '' ? input.accessKey : generateAccessKey(),
206
+ home: typeof input.home === 'string' && input.home !== '' ? input.home : path.join(config.baseDir, id),
207
+ port: input.port ?? 0,
208
+ autostart: input.autostart !== false,
209
+ })
210
+ // 新增即拉起:多租户的意义是"给人一个链接就能用"
211
+ if (tenant.enabled === true && tenant.autostart === true) {
212
+ try {
213
+ start(tenant.id)
214
+ } catch (error) {
215
+ log('租户 ' + tenant.id + ' 已写入注册表,但启动失败:' + String(error?.message ?? error))
216
+ }
217
+ }
218
+ return { tenant, accessKey: tenant.accessKey }
219
+ }
220
+
221
+ /** 删除租户:先停实例,再从注册表移除(它的 home 目录保持原样,不替用户删数据)。 */
222
+ async function remove(id) {
223
+ const tenant = registry.get(id)
224
+ if (tenant === null) return null
225
+ await stop(id)
226
+ instances.delete(id)
227
+ states.delete(id)
228
+ registry.remove(id)
229
+ return tenant
230
+ }
231
+
232
+ function rotate(id) {
233
+ const tenant = registry.rotateKey(id)
234
+ // 旧 Cookie 立刻失效:gate 会按 id 查回租户,但 Cookie 里的签名密钥没变,
235
+ // 真正让它失效的是"密钥换了 → 新链接用新密钥",旧的 ?k= 直接 404。
236
+ return tenant
237
+ }
238
+
239
+ return {
240
+ enabled: config.enabled === true,
241
+ registry,
242
+ harness: {
243
+ bin: discovered.bin === null ? null : discovered.bin.bin,
244
+ binSource: discovered.bin === null ? null : discovered.bin.source,
245
+ node: discovered.runtime.node,
246
+ nodeSource: discovered.runtime.source,
247
+ problem: harnessProblem(),
248
+ },
249
+ load,
250
+ list,
251
+ describe,
252
+ handle,
253
+ findByKey: (key) => {
254
+ const tenant = registry.findByKey(key)
255
+ return tenant === null ? null : handle(tenant.id)
256
+ },
257
+ add,
258
+ remove,
259
+ rotate,
260
+ start,
261
+ stop,
262
+ startAutostart,
263
+ stopAll,
264
+ /** 仅测试用:某个租户的实例(拿令牌/端口)。 */
265
+ instance: (id) => instances.get(id) ?? null,
266
+ }
267
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * 租户模型与租户注册表(多租户网关的地基)。
3
+ *
4
+ * 为什么需要它:一个 Harness 实例本身就是**单用户**的(会话、凭据、设置、工作区、启动令牌
5
+ * 都在它自己的 `DSH_HOME` 里)。所以"多租户"不是给单个实例加个开关,而是:
6
+ *
7
+ * 一个网关(本插件) ──按租户凭据路由──► 每个租户一个独立 Harness 实例
8
+ * (独立 DSH_HOME / 端口 / 令牌)
9
+ *
10
+ * 本模块只做三件事:校验租户定义、把定义持久化到注册表、按凭据或 id 查租户。
11
+ * 实例的启动与看护在 core/instance.js,请求路由在 core/proxy.js。
12
+ *
13
+ * 注册表是 JSON 文件(默认 `<stateDir>/tenants.json`,0600),写入用「临时文件 + rename」,
14
+ * 避免面板/CLI 同时改的时候留下半个文件。
15
+ *
16
+ * @module dsh-plugin-remote-connect-beta/core/tenant
17
+ */
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import crypto from 'node:crypto'
22
+
23
+ /** 租户 id:小写、数字、连字符;2–32 位;直接用作 DSH profile 名与目录名。 */
24
+ export const TENANT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,31}$/
25
+
26
+ /** 访问密钥形态:URL 安全字符,16–128 位(与 public.accessKey 同规则)。 */
27
+ export const ACCESS_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/
28
+
29
+ /** 默认的租户根目录:每个租户的 DSH_HOME 放在它下面。 */
30
+ export function defaultTenantBaseDir(homeDir = os.homedir()) {
31
+ return path.join(homeDir, 'DSH-tenants')
32
+ }
33
+
34
+ /** 生成一个租户访问密钥(32 字符 base64url ≈ 192 bit)。 */
35
+ export function generateAccessKey() {
36
+ return crypto.randomBytes(24).toString('base64url')
37
+ }
38
+
39
+ /** 把用户输入的显示名收敛成合法 id(中文名等无法直接当目录名)。 */
40
+ export function slugifyId(value, fallback = 't') {
41
+ const ascii = String(value ?? '')
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, '-')
44
+ .replace(/^-+|-+$/g, '')
45
+ .slice(0, 32)
46
+ if (TENANT_ID_PATTERN.test(ascii)) return ascii
47
+ // 全是非 ASCII(例如中文名):用调用方给的兜底前缀 + 随机尾巴,保证唯一且合法
48
+ const tail = crypto.randomBytes(3).toString('hex')
49
+ const head = ascii.replace(/[^a-z0-9]/g, '').slice(0, 8) || fallback
50
+ return (head + '-' + tail).slice(0, 32)
51
+ }
52
+
53
+ function asRecord(value) {
54
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
55
+ }
56
+
57
+ function asString(value, fallback = '') {
58
+ return typeof value === 'string' ? value : fallback
59
+ }
60
+
61
+ /**
62
+ * 校验并归一化一个租户定义。
63
+ *
64
+ * 语义与插件其它配置一致:**格式非法**报错(id 不合法、端口越界、home 不是绝对路径……),
65
+ * "还没配好"(例如端口留空让系统分配)不算非法。
66
+ *
67
+ * @param {unknown} raw
68
+ * @param {object} [options]
69
+ * @param {string} [options.baseDir] 相对 home 的解析基准(默认 `~/DSH-tenants`)
70
+ * @param {number} [options.port] 已是该租户占用的端口时允许重复(更新场景)
71
+ * @returns {{ problems: string[], tenant: object }}
72
+ */
73
+ export function normalizeTenant(raw, options = {}) {
74
+ const input = asRecord(raw)
75
+ const problems = []
76
+ const baseDir = options.baseDir ?? defaultTenantBaseDir()
77
+
78
+ const id = asString(input.id).trim()
79
+ if (!TENANT_ID_PATTERN.test(id)) {
80
+ problems.push('租户 id 必须是 2–32 位小写字母/数字/连字符:' + JSON.stringify(id))
81
+ }
82
+
83
+ const accessKey = asString(input.accessKey).trim()
84
+ if (!ACCESS_KEY_PATTERN.test(accessKey)) {
85
+ problems.push('租户 ' + (id || '?') + ' 的 accessKey 需为 16–128 位 URL 安全字符([A-Za-z0-9_-])')
86
+ }
87
+
88
+ const rawHome = asString(input.home).trim()
89
+ const home = rawHome === '' ? path.join(baseDir, id || 'tenant') : path.resolve(rawHome.replace(/^~(?=\/|$)/, os.homedir()))
90
+ if (!path.isAbsolute(home)) problems.push('租户 ' + (id || '?') + ' 的 home 必须是绝对路径')
91
+
92
+ const profileRaw = asString(input.profile).trim()
93
+ const profile = profileRaw === '' ? id : profileRaw
94
+ if (!TENANT_ID_PATTERN.test(profile)) {
95
+ problems.push('租户 ' + (id || '?') + ' 的 profile 名不合法(同 id 规则):' + JSON.stringify(profile))
96
+ }
97
+
98
+ const portRaw = input.port === undefined || input.port === null || input.port === '' ? 0 : Number(input.port)
99
+ if (!Number.isSafeInteger(portRaw) || portRaw < 0 || portRaw > 65535) {
100
+ problems.push('租户 ' + (id || '?') + ' 的 port 必须是 0–65535(0 = 由系统分配)')
101
+ }
102
+
103
+ const name = asString(input.name).trim() || id
104
+ const note = asString(input.note)
105
+
106
+ return {
107
+ problems,
108
+ tenant: {
109
+ id,
110
+ name,
111
+ accessKey,
112
+ home,
113
+ profile,
114
+ port: Number.isSafeInteger(portRaw) ? portRaw : 0,
115
+ // 默认自动拉起:多租户的意义就是"别人打开就能用",不该还要房主手动开
116
+ autostart: input.autostart !== false,
117
+ enabled: input.enabled !== false,
118
+ note,
119
+ createdAt: asString(input.createdAt) || new Date().toISOString(),
120
+ },
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 租户注册表:一个 JSON 文件 + 内存缓存。
126
+ *
127
+ * @param {object} options
128
+ * @param {string} options.file 注册表文件路径
129
+ * @param {string} [options.baseDir] home 的默认父目录
130
+ * @param {(line: string) => void} [options.log]
131
+ */
132
+ export function createRegistry(options) {
133
+ const file = options.file
134
+ const baseDir = options.baseDir ?? defaultTenantBaseDir()
135
+ const log = options.log ?? (() => {})
136
+ /** @type {Map<string, object>} */
137
+ const tenants = new Map()
138
+
139
+ function load() {
140
+ tenants.clear()
141
+ let text = ''
142
+ try {
143
+ text = fs.readFileSync(file, 'utf8')
144
+ } catch (error) {
145
+ if (error?.code !== 'ENOENT') log('租户注册表读取失败:' + String(error?.message ?? error))
146
+ return { loaded: 0, dropped: [] }
147
+ }
148
+ let parsed
149
+ try {
150
+ parsed = JSON.parse(text)
151
+ } catch (error) {
152
+ log('租户注册表不是合法 JSON,已忽略:' + String(error?.message ?? error))
153
+ return { loaded: 0, dropped: [] }
154
+ }
155
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.tenants) ? parsed.tenants : []
156
+ const dropped = []
157
+ for (const item of list) {
158
+ const { problems, tenant } = normalizeTenant(item, { baseDir })
159
+ if (problems.length > 0) {
160
+ dropped.push({ id: asString(asRecord(item).id, '?'), problems })
161
+ continue
162
+ }
163
+ if (tenants.has(tenant.id)) {
164
+ dropped.push({ id: tenant.id, problems: ['重复的 id,只保留第一条'] })
165
+ continue
166
+ }
167
+ tenants.set(tenant.id, tenant)
168
+ }
169
+ if (dropped.length > 0) {
170
+ log('租户注册表有 ' + String(dropped.length) + ' 条被忽略:' + dropped.map((d) => d.id + '(' + d.problems.join(';') + ')').join(','))
171
+ }
172
+ return { loaded: tenants.size, dropped }
173
+ }
174
+
175
+ function persist() {
176
+ const list = [...tenants.values()]
177
+ const dir = path.dirname(file)
178
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
179
+ const tmp = file + '.tmp-' + String(process.pid)
180
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, tenants: list }, null, 2) + '\n', { mode: 0o600 })
181
+ fs.renameSync(tmp, file)
182
+ return list
183
+ }
184
+
185
+ function list() {
186
+ return [...tenants.values()].sort((a, b) => a.id.localeCompare(b.id))
187
+ }
188
+
189
+ function get(id) {
190
+ return tenants.get(String(id)) ?? null
191
+ }
192
+
193
+ function findByKey(key) {
194
+ if (typeof key !== 'string' || key === '') return null
195
+ for (const tenant of tenants.values()) {
196
+ if (tenant.accessKey.length === key.length && crypto.timingSafeEqual(Buffer.from(tenant.accessKey), Buffer.from(key))) {
197
+ return tenant
198
+ }
199
+ }
200
+ return null
201
+ }
202
+
203
+ function add(raw) {
204
+ // 没给密钥就自动生成:调用方(CLI / 面板 / 组合)不该为了拿一个密钥去拼 crypto
205
+ const input = asRecord(raw)
206
+ const candidate = asString(input.accessKey).trim() === '' ? { ...input, accessKey: generateAccessKey() } : input
207
+ const { problems, tenant } = normalizeTenant(candidate, { baseDir })
208
+ if (problems.length > 0) {
209
+ const error = new Error(problems.join(';'))
210
+ error.problems = problems
211
+ throw error
212
+ }
213
+ if (tenants.has(tenant.id)) {
214
+ const error = new Error('租户 ' + tenant.id + ' 已存在')
215
+ error.problems = [error.message]
216
+ throw error
217
+ }
218
+ // 密钥必须唯一:否则两个租户会互相串门
219
+ if (findByKey(tenant.accessKey) !== null) {
220
+ const error = new Error('该访问密钥已被别的租户占用')
221
+ error.problems = [error.message]
222
+ throw error
223
+ }
224
+ tenants.set(tenant.id, tenant)
225
+ persist()
226
+ return tenant
227
+ }
228
+
229
+ function update(id, patch) {
230
+ const current = get(id)
231
+ if (current === null) throw new Error('租户不存在:' + String(id))
232
+ const { problems, tenant } = normalizeTenant({ ...current, ...asRecord(patch), id: current.id }, { baseDir })
233
+ if (problems.length > 0) {
234
+ const error = new Error(problems.join(';'))
235
+ error.problems = problems
236
+ throw error
237
+ }
238
+ const clash = findByKey(tenant.accessKey)
239
+ if (clash !== null && clash.id !== current.id) throw new Error('该访问密钥已被别的租户占用')
240
+ tenants.set(tenant.id, tenant)
241
+ persist()
242
+ return tenant
243
+ }
244
+
245
+ function remove(id) {
246
+ const current = get(id)
247
+ if (current === null) return null
248
+ tenants.delete(id)
249
+ persist()
250
+ return current
251
+ }
252
+
253
+ /** 换一个访问密钥(旧链接与旧 Cookie 立刻失效)。 */
254
+ function rotateKey(id) {
255
+ return update(id, { accessKey: generateAccessKey() })
256
+ }
257
+
258
+ return {
259
+ file,
260
+ baseDir,
261
+ load,
262
+ list,
263
+ get,
264
+ findByKey,
265
+ add,
266
+ update,
267
+ remove,
268
+ rotateKey,
269
+ /** 仅测试/排障用:直接看内存里的原始映射。 */
270
+ size: () => tenants.size,
271
+ }
272
+ }