dsh-ris-yds 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,267 @@
1
+ // dsh-ris-yds plugin entry: tools-only (no browser row). Every agent tool
2
+ // funnels into lib/cli.js so the shell CLI, the dsh-ris-ide startCommand and
3
+ // the agent tools behave identically.
4
+
5
+ import { reconcileWorkspaceConfig } from './ide.js'
6
+ import { watchAll } from './ide-watch.js'
7
+
8
+ export const name = 'dsh-ris-yds'
9
+ export const inject = ['sessions', 'tools']
10
+
11
+ export function apply(ctx) {
12
+ // Keep the launcher row in ris-ide's config after it regenerates the file.
13
+ // We only observe (see lib/ide-watch.js): ris-ide is never modified.
14
+ let stopWatching = () => {}
15
+ try {
16
+ stopWatching = watchAll(
17
+ { repair: (workspace) => reconcileWorkspaceConfig(workspace) },
18
+ { log: ctx.logger?.info?.bind(ctx.logger) ?? (() => {}) },
19
+ )
20
+ } catch (error) {
21
+ ctx.logger?.warn?.(`dsh-ris-yds: config watcher not started: ${String(error)}`)
22
+ }
23
+ if (typeof ctx.tools?.register !== 'function') {
24
+ ctx.logger?.warn?.('dsh-ris-yds: ctx.tools missing; tools stay unregistered')
25
+ return
26
+ }
27
+ const disposers = []
28
+ const register = (definition) => {
29
+ try {
30
+ disposers.push(ctx.tools.register(definition))
31
+ } catch (error) {
32
+ ctx.logger?.warn?.(`dsh-ris-yds: tool ${definition.name} not registered: ${String(error)}`)
33
+ }
34
+ }
35
+ const scopeOf = (exec) => {
36
+ const sessionId = exec?.agent?.session?.id
37
+ if (typeof sessionId !== 'string' || sessionId === '') {
38
+ throw new Error('this tool needs a live agent session')
39
+ }
40
+ const session = ctx.sessions?.get?.(sessionId)
41
+ const headerCwd = session?.header?.cwd
42
+ if (typeof headerCwd === 'string' && headerCwd !== '') return headerCwd
43
+ if (typeof exec?.cwd === 'string' && exec.cwd !== '') return exec.cwd
44
+ throw new Error(`no working directory for session "${sessionId}"`)
45
+ }
46
+ const text = (value) => [{ type: 'text', text: value }]
47
+
48
+ const LOOSE_OUTPUT = {
49
+ type: 'object',
50
+ additionalProperties: true,
51
+ properties: { ok: { type: 'boolean' } },
52
+ }
53
+
54
+ const runCli = async (exec, argv) => {
55
+ const { run } = await import('./cli.js')
56
+ const cwd = scopeOf(exec)
57
+ exec?.signal?.throwIfAborted?.()
58
+ const result = await run(argv, { cwd })
59
+ if (result === undefined) return result
60
+ // Tools never touch process.exitCode (that would leak into the dsh host); they
61
+ // report the outcome in the payload instead.
62
+ const { exitCode = 0, ...rest } = result
63
+ return { ...rest, ok: exitCode === 0 }
64
+ }
65
+
66
+ register({
67
+ name: 'yds_doctor',
68
+ description: 'YDS 环境体检:解析项目 settings.json 与可选 .env 的生效配置(SOCKS/直连、回调地址、端口、JDK),'
69
+ + '检查 yms-home 构建状态、YMS 进程/端口/ready、socks 转发器,并列出缺失项。'
70
+ + '新环境装上插件后从这里开始;配置缺失会给出精确的补齐指引。',
71
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
72
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
73
+ execute: async (args, exec) => runCli(exec, ['doctor']),
74
+ })
75
+
76
+ register({
77
+ name: 'yds_config',
78
+ description: '显示 YDS 生效配置(键值+来源:项目 settings.json / workspace .env / 环境变量),凭据打码。'
79
+ + '含 module、workbenchUrl、serverBindAddress/serverPort(回调)、ymsPort(本地监听)、socks、git、maven。',
80
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
81
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
82
+ execute: async (args, exec) => runCli(exec, ['config']),
83
+ })
84
+
85
+ register({
86
+ name: 'yds_token',
87
+ description: '生成调用本地 YMS 或远端平台 API 的 YYCToken(请求头 YYCToken)。token 与 URL 绑定宽松(根 URL token 可打任意路径)。'
88
+ + '凭据自动取自项目 settings.json 的 yds.accessKey/accessSecret。',
89
+ parameters: {
90
+ type: 'object',
91
+ additionalProperties: false,
92
+ required: ['url'],
93
+ properties: { url: { type: 'string', description: '要授权的完整 URL(如 https://<workbench>/c-ass-kk-hans/_yms/ready)' } },
94
+ },
95
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
96
+ execute: async (args, exec) => runCli(exec, ['token', args.url]),
97
+ })
98
+
99
+ register({
100
+ name: 'yds_confcenter',
101
+ description: '拉取(或离线使用缓存)confcenter 的 4 个 YMS 配置文件并适配写入 yms-home/config:'
102
+ + 'SOCKS 模式把 DB/Redis 地址改写为本地转发端口,直连模式保持原地址;ip_node_mapping 指向回调地址。'
103
+ + '--offline 用 .dsh-ris-yds/confcenter 缓存(网络不可达时启动用)。',
104
+ parameters: {
105
+ type: 'object',
106
+ additionalProperties: false,
107
+ properties: { offline: { type: 'boolean', description: '不联网,用上次拉取的缓存' } },
108
+ },
109
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
110
+ execute: async (args, exec) => runCli(exec, ['confcenter', ...(args?.offline === true ? ['--offline'] : [])]),
111
+ })
112
+
113
+ register({
114
+ name: 'yds_build',
115
+ description: '从零构建 yms-home(首次含 77MB kernel 从 jfrog 下载,约 19 分钟;缓存后约 1 分钟):'
116
+ + 'maven yms-module 部署 + confcenter 拉取适配 + JDK 指针。不依赖本机既有产物,新环境可直接跑。'
117
+ + '需要 JDK 与 mvn 在 PATH(或 .env 的 java.path / maven.* 指定)。',
118
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
119
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
120
+ execute: async (args, exec) => runCli(exec, ['build']),
121
+ })
122
+
123
+ register({
124
+ name: 'yds_start',
125
+ description: '后台启动本地 YMS(幂等):socks 已配置则自动起本地转发器,yms-home/config 缺失则自动补(缓存或在线拉取),'
126
+ + '然后以脱离进程启动 Liberty,等端口就绪。返回 pid、端口、rdbga cookie 值(种到 workbench 域即可让网关路由回调本机)。'
127
+ + '已运行时返回 alreadyRunning。需要 JDWP 调试请走 yds_ide + ris_dev_debug。',
128
+ parameters: {
129
+ type: 'object',
130
+ additionalProperties: false,
131
+ properties: { jvmArgs: { type: 'string', description: '额外 JVM 参数(空格分隔)' } },
132
+ },
133
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
134
+ execute: async (args, exec) => runCli(exec, ['start', ...(typeof args?.jvmArgs === 'string' && args.jvmArgs !== '' ? ['--jvm-args', args.jvmArgs] : [])]),
135
+ })
136
+
137
+ register({
138
+ name: 'yds_stop',
139
+ description: '停止本地 YMS(TERM→宽限→KILL,pidfile 验证)。默认同时停 socks 转发器;keepSocks=true 保留(例如共享给其它任务)。',
140
+ parameters: {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ properties: { keepSocks: { type: 'boolean', description: '保留 socks 转发器' } },
144
+ },
145
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
146
+ execute: async (args, exec) => runCli(exec, ['stop', ...(args?.keepSocks === true ? ['--keep-socks'] : [])]),
147
+ })
148
+
149
+ register({
150
+ name: 'yds_status',
151
+ description: 'YMS 运行状态:进程 pid、端口监听、/_yms/ready HTTP 状态、yms-home 构建与 config 就绪、socks 转发器。',
152
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
153
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
154
+ execute: async (args, exec) => runCli(exec, ['status']),
155
+ })
156
+
157
+ register({
158
+ name: 'yds_logs',
159
+ description: 'tail 本地 YMS 日志(Liberty 控制台 + 应用 yms-application.log),默认 80 行。',
160
+ parameters: {
161
+ type: 'object',
162
+ additionalProperties: false,
163
+ properties: { lines: { type: 'number', description: '行数(默认 80)' } },
164
+ },
165
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
166
+ execute: async (args, exec) => runCli(exec, ['logs', '--lines', String(args?.lines ?? 80)]),
167
+ })
168
+
169
+ register({
170
+ name: 'yds_ide',
171
+ description: '维护 dsh-ris-ide 的项目运行配置 .dsh-ris-ide/config.json:把本地 YMS 作为一个程序条目(startCommand 含 ${JDWP_ARGS} 占位符)'
172
+ + 'upsert 进去,之后可用 ris_dev_start/ris_dev_stop 启停、ris_dev_debug 断点调试(JDWP)。'
173
+ + '同时维护项目根 .vscode/launch.json 的 yms-middleware-cloudnative-jdk-21 条目(与 YonCode 生成物同名同构,YonCode 端 F5 可直接启动/调试 YMS)。'
174
+ + '只增改自己的一条(按名字匹配),其余用户/面板/YonCode 条目原样保留。action=check(默认)/apply/remove。',
175
+ parameters: {
176
+ type: 'object',
177
+ additionalProperties: false,
178
+ properties: { action: { type: 'string', description: 'check(默认)| apply | remove' } },
179
+ },
180
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
181
+ execute: async (args, exec) => runCli(exec, ['ide', args?.action ?? 'check']),
182
+ })
183
+
184
+ register({
185
+ name: 'yds_proxy',
186
+ description: '代理引擎(借用 dsh-ris-ide 的 proxy.js,与 IDE 控制台的 🌐 弹窗共享同一份规则与同一个进程):'
187
+ + 'status 看引擎与 yds 规则;sync/start 按 confcenter 缓存的 DB/Redis/内网端点自动生成 source=yds 的规则并启动引擎'
188
+ + '(literal IPv4 目标会在本机 127.0.0.1:同端口 上做映射,供 JDBC/Redis 这类不支持 SOCKS 的客户端使用);'
189
+ + 'stop 停引擎;git-on/git-off 写入/清除 scope 到配置 URL 主机的 git 代理(普通 git 与 dsh-git 都会生效,无需 -c 参数)。'
190
+ + '未安装 dsh-ris-ide 时 yds start 自动回退内置转发器。',
191
+ parameters: {
192
+ type: 'object',
193
+ additionalProperties: false,
194
+ properties: { action: { type: 'string', description: 'status(默认)| sync | start | stop | git-on | git-off' } },
195
+ },
196
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
197
+ execute: async (args, exec) => runCli(exec, ['proxy', args?.action ?? 'status']),
198
+ })
199
+
200
+ register({
201
+ name: 'yds_browser',
202
+ description: '工作台浏览器(YonCode 内置 Chrome / 任意开了 DevTools 端口的浏览器)的 rdbga cookie 体检与校准:'
203
+ + '网关只在该 cookie 等于当前 JVM 的 -Drdbga 时把请求路由到本机;一个空的 host-only rdbga 会覆盖正确值,'
204
+ + '导致页面看着正常但请求全去远端 pod(本机日志空、断点不进)。'
205
+ + 'status 列出 DevTools 端口/工作台页签/期望值(含来源);fix 删除空值/重复/不匹配的 rdbga 并写入当前值。'
206
+ + 'fix 默认只预览(dry_run=true),确认后才用 dry_run=false 真正写入;只动 rdbga、只动工作台域。'
207
+ + 'yds start 成功后会自动校准一次(后台/前台/已在运行都会,浏览器没开则跳过)。',
208
+ parameters: {
209
+ type: 'object',
210
+ additionalProperties: false,
211
+ properties: {
212
+ action: { type: 'string', description: 'status(默认)| fix' },
213
+ dry_run: { type: 'boolean', description: 'fix 默认 true=只预览;真正写入浏览器 cookie 需显式 false' },
214
+ },
215
+ },
216
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
217
+ execute: async (args, exec) => {
218
+ const action = args?.action ?? 'status'
219
+ const argv = ['browser', action]
220
+ if (action === 'fix' && args?.dry_run !== false) argv.push('--dry-run')
221
+ return runCli(exec, argv)
222
+ },
223
+ })
224
+
225
+ register({
226
+ name: 'yds_yoncode',
227
+ description: 'YonCode 的 serverBindAddress 网卡校验(YonCode 要求启动地址是本机网卡 IP;本插件用回调地址/端口转发时会拒绝启动):'
228
+ + 'status 检测安装位置与状态(patched/unpatched/absent,可用 .env 的 yoncode.home 指定安装目录);'
229
+ + 'patch 把该校验改成告警(写 YonCode 安装目录,先备份 .bak;写不动会提示需要管理员/danger-full-access 提权);'
230
+ + 'revert 从 .bak 还原。只改 YonCode 自己的文件,不动项目。',
231
+ parameters: {
232
+ type: 'object',
233
+ additionalProperties: false,
234
+ properties: { action: { type: 'string', description: 'status(默认)| patch | revert' } },
235
+ },
236
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
237
+ execute: async (args, exec) => runCli(exec, ['yoncode', args?.action ?? 'status']),
238
+ })
239
+
240
+ register({
241
+ name: 'yds_fe',
242
+ description: '前端调试(webpack dev server)的两种接入方式,值全部取自当前项目配置(module/domainKey、workbench 主机、yds.webConfig 里的 dev 端口):'
243
+ + 'rules 生成 Resource Override Chrome 插件的规则文件(写到 <workspace>/.dsh-ris-yds/resource-override-rules.json,'
244
+ + '在插件里导入即可把远端 extend*.min.js/css 换成 127.0.0.1:<dev端口>/static 本地文件);'
245
+ + 'plan 打印同样的规则转换成的「正则 + 替换」清单,agent 可以用 CDP Fetch 拦截自行注入(不需要装扩展);'
246
+ + 'url <page> 给页面 URL 追加 scriptUrl 参数(零配置路径,前端自己支持)。',
247
+ parameters: {
248
+ type: 'object',
249
+ additionalProperties: false,
250
+ properties: {
251
+ action: { type: 'string', description: 'rules(默认)| plan | url' },
252
+ page: { type: 'string', description: 'action=url 时的页面 URL(绝对或站内路径)' },
253
+ },
254
+ },
255
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
256
+ execute: async (args, exec) => {
257
+ const argv = ['fe', args?.action ?? 'rules']
258
+ if (typeof args?.page === 'string' && args.page !== '') argv.push(args.page)
259
+ return runCli(exec, argv)
260
+ },
261
+ })
262
+
263
+ return () => {
264
+ try { stopWatching() } catch { /* ignore */ }
265
+ disposers.forEach((dispose) => { try { dispose() } catch { /* ignore */ } })
266
+ }
267
+ }
package/lib/socks.js ADDED
@@ -0,0 +1,260 @@
1
+ // SOCKS forwarder: local TCP listeners that tunnel through a SOCKS5 proxy to
2
+ // the real DB/Redis. Only started when .env carries socks.host;
3
+ // without it everything connects directly (requirement 2).
4
+ //
5
+ // The forwarder runs as its own detached process (`yds socks-fwd`) with a
6
+ // pidfile in .dsh-ris-yds/; start/stop are idempotent.
7
+
8
+ import { spawn } from 'node:child_process'
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import net from 'node:net'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ export function socksPidFile(cfg) {
15
+ return join(cfg.stateDir, 'socks-forward.pid')
16
+ }
17
+
18
+ /**
19
+ * Verify a pid is really our process (guards against pid reuse after a
20
+ * crash/reboot: a bare pid from a stale pidfile could be an unrelated
21
+ * process — never signal it). Identity = /proc/<pid>/cmdline contains our
22
+ * marker. Returns false when /proc is unavailable (non-Linux): the caller
23
+ * then treats the pid as unverified and refuses to kill it.
24
+ */
25
+ export function pidIsOurs(pid, marker) {
26
+ if (!Number.isInteger(pid) || pid <= 0) return false
27
+ try {
28
+ const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8')
29
+ return cmd.includes(marker)
30
+ } catch { return false }
31
+ }
32
+
33
+ /** Read + verify the forwarder pidfile; null when absent/stale/foreign. */
34
+ export function forwarderPid(cfg) {
35
+ const pidFile = socksPidFile(cfg)
36
+ if (!existsSync(pidFile)) return null
37
+ const pid = Number(readFileSync(pidFile, 'utf8').trim())
38
+ if (!Number.isInteger(pid) || pid <= 0 || !pidIsOurs(pid, 'socks-fwd')) {
39
+ try { if (Number.isInteger(pid) && pid > 0 && !pidIsOurs(pid, 'socks-fwd') && !pidIsAlive(pid)) unlinkSync(pidFile) } catch { /* ignore */ }
40
+ return null
41
+ }
42
+ return pid
43
+ }
44
+
45
+ function pidIsAlive(pid) {
46
+ try { process.kill(pid, 0); return true } catch { return false }
47
+ }
48
+
49
+ export function portOpen(host, port, timeout = 1500) {
50
+ return new Promise((resolve_) => {
51
+ const sock = net.connect({ host, port })
52
+ const done = (ok) => { sock.destroy(); resolve_(ok) }
53
+ sock.setTimeout(timeout, () => done(false))
54
+ sock.once('connect', () => done(true))
55
+ sock.once('error', () => done(false))
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Our forwarder is "up" only when the pidfile pid is alive AND is ours AND
61
+ * the port answers. A port answering without our pid = some other local
62
+ * service (e.g. a local Oracle on 1521) — NOT the tunnel.
63
+ */
64
+ export function isForwarderUp(cfg) {
65
+ return (async () => {
66
+ const pid = forwarderPid(cfg)
67
+ if (pid === null) return false
68
+ return (await portOpen('127.0.0.1', cfg.dbForwardPort, 400)) || (await portOpen('127.0.0.1', cfg.redisForwardPort, 400))
69
+ })()
70
+ }
71
+
72
+ /**
73
+ * Spawn the detached forwarder process (idempotent). `targets` come from the
74
+ * confcenter adaptation: [{ host, port, localPort?, isRedis? }] — an explicit
75
+ * localPort (yonbip extras: kafka/ES/license keep their own port number)
76
+ * wins, db gets dbForwardPort, redis gets redisForwardPort.
77
+ */
78
+ export async function startForwarder(cfg, targets, { logPath } = {}) {
79
+ if (cfg.socks === null) return { started: false, reason: 'socks 未配置(直连模式,无需转发)' }
80
+ if (await isForwarderUp(cfg)) {
81
+ return { started: false, reason: '转发器已在监听' }
82
+ }
83
+ await stopForwarder(cfg)
84
+ mkdirSync(cfg.stateDir, { recursive: true })
85
+ const specs = []
86
+ for (const t of targets) {
87
+ const localPort = Number.isInteger(t.localPort) ? t.localPort
88
+ : (t.isRedis === true ? cfg.redisForwardPort : cfg.dbForwardPort)
89
+ if (specs.some((s) => s.local === localPort)) continue
90
+ specs.push({ local: localPort, host: t.host, port: t.port })
91
+ }
92
+ if (specs.length === 0) specs.push({ local: cfg.dbForwardPort, host: '', port: 0 }) // never used
93
+ const args = specs.map((s) => `${s.local}:${s.host}:${s.port}`)
94
+ const bin = fileURLToPath(new URL('../bin/yds.js', import.meta.url))
95
+ const out = logPath ?? join(cfg.stateDir, 'socks-forward.log')
96
+ const { openSync } = await import('node:fs')
97
+ const outFd = openSync(out, 'a')
98
+ const child = spawn(process.execPath, [bin, 'socks-fwd', `--socks=${cfg.socks.host}:${cfg.socks.port}`, ...args], {
99
+ detached: true,
100
+ stdio: ['ignore', outFd, outFd],
101
+ env: process.env,
102
+ })
103
+ child.unref()
104
+ writeFileSync(socksPidFile(cfg), String(child.pid), 'utf8')
105
+ const portsUp = async () => {
106
+ // every spec must listen; report the first one that does not
107
+ for (const s of specs) {
108
+ // eslint-disable-next-line no-await-in-loop
109
+ if (!(await portOpen('127.0.0.1', s.local, 150))) return s.local
110
+ }
111
+ return null
112
+ }
113
+ for (let i = 0; i < 40; i += 1) {
114
+ // eslint-disable-next-line no-await-in-loop
115
+ const down = await portsUp()
116
+ if (down === null) break
117
+ // eslint-disable-next-line no-await-in-loop
118
+ await new Promise((r) => setTimeout(r, 200))
119
+ }
120
+ // the wait loop above can exhaust without the port ever answering (child
121
+ // crashed instantly, e.g. EADDRINUSE) — verify before reporting success.
122
+ const down = await portsUp()
123
+ const alive = pidIsAlive(child.pid)
124
+ if (down !== null || !alive) {
125
+ try { unlinkSync(socksPidFile(cfg)) } catch { /* ignore */ }
126
+ throw new Error(`socks 转发器启动失败(端口 ${down ?? '?'} 未监听 / 进程存活=${alive})——详情见 ${out}(端口被占或其他本地服务在 127.0.0.1:${down}?)`)
127
+ }
128
+ return { started: true, pid: child.pid, targets: args, log: out }
129
+ }
130
+
131
+ /** Stop the detached forwarder if its pidfile pid is ours (never kill a reused foreign pid). */
132
+ export async function stopForwarder(cfg) {
133
+ const pid = forwarderPid(cfg)
134
+ if (pid === null) return { stopped: false, reason: 'pidfile 不存在或非本工具进程' }
135
+ const pidFile = socksPidFile(cfg)
136
+ try { process.kill(pid, 'SIGTERM') } catch { /* already gone */ }
137
+ for (let i = 0; i < 25; i += 1) {
138
+ try {
139
+ process.kill(pid, 0)
140
+ // eslint-disable-next-line no-await-in-loop
141
+ await new Promise((r) => setTimeout(r, 200))
142
+ } catch { break }
143
+ }
144
+ try { process.kill(pid, 'SIGKILL') } catch { /* gone */ }
145
+ try { unlinkSync(pidFile) } catch { /* ignore */ }
146
+ return { stopped: true, pid }
147
+ }
148
+
149
+ /** SOCKS5 no-auth handshake + CONNECT, promise-based (proven implementation). */
150
+ const SOCKS_CONNECT_TIMEOUT_MS = 15000
151
+
152
+ function connectSocks(targetHost, targetPort, socksHost, socksPort) {
153
+ return new Promise((resolve_, reject_) => {
154
+ const sock = net.connect(socksPort, socksHost)
155
+ const timer = setTimeout(() => { try { sock.destroy() } catch { /* ignore */ } reject_(new Error('socks connect timeout')) }, SOCKS_CONNECT_TIMEOUT_MS)
156
+ const settle = (fn) => (arg) => { clearTimeout(timer); fn(arg) }
157
+ sock.once('error', settle(reject_))
158
+ sock.once('connect', () => {
159
+ sock.write(Buffer.from([0x05, 0x01, 0x00])) // greeting: no-auth
160
+ sock.once('data', (buf) => {
161
+ if (buf.length < 2 || buf[0] !== 0x05) { sock.destroy(); return reject_(new Error('bad socks greeting')) }
162
+ if (buf[1] !== 0x00) { sock.destroy(); return reject_(new Error('socks requires auth')) }
163
+ const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(targetHost)
164
+ let req
165
+ if (isIp) {
166
+ req = Buffer.alloc(10)
167
+ req[0] = 0x05; req[1] = 0x01; req[3] = 0x01
168
+ targetHost.split('.').forEach((o, i) => { req[4 + i] = Number(o) })
169
+ req.writeUInt16BE(targetPort, 8)
170
+ } else {
171
+ const name = Buffer.from(targetHost, 'utf8')
172
+ req = Buffer.alloc(7 + name.length)
173
+ req[0] = 0x05; req[1] = 0x01; req[3] = 0x03; req[4] = name.length
174
+ name.copy(req, 5)
175
+ req.writeUInt16BE(targetPort, 5 + name.length)
176
+ }
177
+ sock.write(req)
178
+ let acc = Buffer.alloc(0)
179
+ const onReply = (buf) => {
180
+ acc = Buffer.concat([acc, buf])
181
+ if (acc.length < 4) return
182
+ const atyp = acc[3]
183
+ const need = atyp === 0x01 ? 10 : (atyp === 0x03 ? 5 + acc[4] + 2 : (atyp === 0x04 ? 22 : -1))
184
+ if (need < 0 || acc.length < need) return
185
+ sock.removeListener('data', onReply)
186
+ if (acc[1] !== 0x00) { sock.destroy(); return reject_(new Error(`socks CONNECT failed rep=${acc[1]}`)) }
187
+ if (acc.length > need) sock.unshift(acc.subarray(need))
188
+ clearTimeout(timer)
189
+ resolve_(sock)
190
+ }
191
+ sock.on('data', onReply)
192
+ })
193
+ })
194
+ })
195
+ }
196
+
197
+ /** The forwarder loop (runs inside `yds socks-fwd`). */
198
+ /**
199
+ * Health probe: full SOCKS5 handshake + CONNECT to one target. Returns true
200
+ * when the tunnel actually forwards (rep=0). Distinguishes "forwarder
201
+ * listening" from "remote machine can still reach the resource" — the
202
+ * one-way tunnel on the remote dev machine can go down independently.
203
+ */
204
+ export function probeSocksConnect(socks, target, timeout = 8000) {
205
+ return new Promise((resolve_) => {
206
+ let phase = 0
207
+ let settled = false
208
+ const done = (v) => { if (!settled) { settled = true; resolve_(v) } }
209
+ let socket
210
+ try { socket = net.connect(socks.port, socks.host) } catch { resolve_(false); return }
211
+ socket.setTimeout(timeout, () => { done(false); socket.destroy() })
212
+ socket.on('error', () => done(false))
213
+ socket.on('connect', () => {
214
+ try { socket.write(Buffer.from([5, 1, 0])) } catch { done(false) }
215
+ })
216
+ socket.on('data', (d) => {
217
+ if (settled) return
218
+ if (phase === 0) {
219
+ if (d.length < 2 || d[0] !== 5 || d[1] !== 0) { done(false); socket.destroy(); return }
220
+ phase = 1
221
+ const host = Buffer.from(target.host, 'utf8')
222
+ const port = target.port
223
+ const req = Buffer.from([5, 1, 0, 3, host.length, ...host, Math.floor(port / 256), port % 256])
224
+ try { socket.write(req) } catch { done(false) }
225
+ } else {
226
+ done(d.length >= 2 && d[1] === 0)
227
+ socket.destroy()
228
+ }
229
+ })
230
+ })
231
+ }
232
+
233
+ export function runForwarder(socksHost, socksPort, specs) {
234
+ console.log(`[yds] socks=${socksHost}:${socksPort}`)
235
+ let failures = 0
236
+ for (const s of specs) {
237
+ const server = net.createServer((client) => {
238
+ connectSocks(s.host, s.port, socksHost, socksPort).then((remote) => {
239
+ client.pipe(remote)
240
+ remote.pipe(client)
241
+ const done = () => { try { client.destroy(); remote.destroy() } catch { /* ignore */ } }
242
+ client.on('error', done); remote.on('error', done)
243
+ client.on('close', done); remote.on('close', done)
244
+ }).catch((e) => {
245
+ console.error(`[yds] ${s.local}->${s.host}:${s.port} failed: ${e.message}`)
246
+ client.destroy()
247
+ })
248
+ })
249
+ // without this listener EADDRINUSE (or any bind failure) becomes an
250
+ // uncaught exception in the detached child — parent then reports success.
251
+ server.on('error', (e) => {
252
+ failures += 1
253
+ console.error(`[yds] listen 127.0.0.1:${s.local} failed: ${e.message}`)
254
+ if (failures >= specs.length) process.exit(1)
255
+ })
256
+ server.listen(s.local, '127.0.0.1', () => {
257
+ console.log(`[yds] forward 127.0.0.1:${s.local} -> ${s.host}:${s.port}`)
258
+ })
259
+ }
260
+ }
package/lib/token.js ADDED
@@ -0,0 +1,36 @@
1
+ // YYCToken generation. The signing key derivation lives in the closed-source
2
+ // yds-rust native module, so we load the prebuilt binary per platform from
3
+ // native/ (darwin/linux/win32 × x64/arm64 — extracted from the YonCode
4
+ // extension, bundled here so the plugin needs no YonCode install).
5
+
6
+ import { existsSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { pathToFileURL } from 'node:url'
9
+ import { PLUGIN_DIR } from './config.js'
10
+
11
+ let cached = null
12
+
13
+ function loadNative() {
14
+ if (cached !== null) return cached
15
+ const file = join(PLUGIN_DIR, 'native', `yds-rust.${process.platform}-${process.arch}.node`)
16
+ if (!existsSync(file)) {
17
+ throw new Error(`当前平台无 yds-rust 原生模块: ${process.platform}-${process.arch} (${file})`)
18
+ }
19
+ cached = { file, mod: null }
20
+ return cached
21
+ }
22
+
23
+ /**
24
+ * Generate a YYCToken for a URL.
25
+ * @param {string} url - the exact request URL the token will authorize.
26
+ * @param {string} accessKey
27
+ * @param {string} accessSecret
28
+ * @returns {Promise<string>} the token (URL-bound; regenerate per URL).
29
+ */
30
+ export async function getYycToken(url, accessKey, accessSecret) {
31
+ const entry = loadNative()
32
+ if (entry.mod === null) entry.mod = await import(pathToFileURL(entry.file).href) // pathToFileURL: survives spaces on Windows
33
+ const fn = entry.mod.getYycToken ?? entry.mod.default?.getYycToken
34
+ if (typeof fn !== 'function') throw new Error('yds-rust 模块缺少 getYycToken 导出')
35
+ return fn(url, accessKey, accessSecret)
36
+ }