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/LICENSE +21 -0
- package/README.md +141 -2
- package/assets/yds-chrome-resource-override.crx +0 -0
- package/bin/check-listing.mjs +247 -0
- package/bin/yds.js +19 -0
- package/cordis.patch.yml +13 -0
- package/lib/browser.js +428 -0
- package/lib/build.js +115 -0
- package/lib/cli.js +1003 -0
- package/lib/confcenter.js +377 -0
- package/lib/config.js +552 -0
- package/lib/fe-dev.js +206 -0
- package/lib/ide-proxy.js +682 -0
- package/lib/ide-watch.js +188 -0
- package/lib/ide.js +702 -0
- package/lib/index.js +267 -0
- package/lib/socks.js +260 -0
- package/lib/token.js +36 -0
- package/lib/yms.js +302 -0
- package/lib/yoncode.js +249 -0
- package/native/yds-rust.darwin-arm64.node +0 -0
- package/native/yds-rust.darwin-x64.node +0 -0
- package/native/yds-rust.linux-arm64.node +0 -0
- package/native/yds-rust.linux-x64.node +0 -0
- package/native/yds-rust.win32-arm64.node +0 -0
- package/native/yds-rust.win32-x64.node +0 -0
- package/package.json +58 -4
- package/skills/dsh-ris-yds/SKILL.md +134 -0
- package/test/browser.test.mjs +442 -0
- package/test/cli.test.mjs +246 -0
- package/test/config.test.mjs +106 -0
- package/test/fe-dev.test.mjs +93 -0
- package/test/ide-config.test.mjs +151 -0
- package/test/ide-proxy.test.mjs +194 -0
- package/test/ide-watch.test.mjs +79 -0
- package/test/launch-proxy.test.mjs +83 -0
- package/test/mode-separation.test.mjs +147 -0
- package/test/proxy-standalone.test.mjs +118 -0
- package/test/yoncode.test.mjs +159 -0
package/lib/ide-proxy.js
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// Borrow dsh-ris-ide's proxy engine instead of running a second tunnel stack.
|
|
2
|
+
//
|
|
3
|
+
// The IDE plugin owns `.dsh-ris-ide/proxy.json` and a single engine process that
|
|
4
|
+
// can (a) bind `127.0.0.1:<port>` shadows for literal IPv4 targets and (b) serve
|
|
5
|
+
// an HTTP CONNECT proxy restricted by the rule table. YDS needs exactly (a): the
|
|
6
|
+
// YMS runtime's JDBC/Redis endpoints are rewritten to `127.0.0.1:<same port>`, so
|
|
7
|
+
// the engine's shadows ARE the old `socks-forward.js` — with one owner for the
|
|
8
|
+
// ports, one pidfile, and a UI the user can inspect.
|
|
9
|
+
//
|
|
10
|
+
// So this module does not transport anything by itself: it generates the
|
|
11
|
+
// `source: 'yds'` rules (replaced wholesale on every start, user rows untouched),
|
|
12
|
+
// makes sure the engine is running them, and (optionally) writes the scoped git
|
|
13
|
+
// proxy entry so plain `git` commands — not just ones we spawn — honour SOCKS.
|
|
14
|
+
//
|
|
15
|
+
// When dsh-ris-ide is not installed the caller falls back to `lib/socks.js`, so
|
|
16
|
+
// the plugin still works on a machine that has only dsh-ris-yds.
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { yoncodeCandidates } from './yoncode.js'
|
|
22
|
+
import { pathToFileURL } from 'node:url'
|
|
23
|
+
import { spawn } from 'node:child_process'
|
|
24
|
+
|
|
25
|
+
/** Marker `source` used for every rule this plugin owns. */
|
|
26
|
+
export const YDS_RULE_SOURCE = 'yds'
|
|
27
|
+
|
|
28
|
+
/** Where a dsh-ris-ide checkout/install may keep its proxy engine. */
|
|
29
|
+
export function ideProxyCandidates(cfg) {
|
|
30
|
+
const list = []
|
|
31
|
+
const explicit = process.env.YDS_IDE_PROXY
|
|
32
|
+
if (typeof explicit === 'string' && explicit !== '') list.push(explicit)
|
|
33
|
+
const workspace = typeof cfg?.workspace === 'string' ? cfg.workspace : ''
|
|
34
|
+
if (workspace !== '') {
|
|
35
|
+
// A link-installed plugin lives in the profile's node_modules; a workspace
|
|
36
|
+
// install (or a sibling checkout) is the other common layout.
|
|
37
|
+
list.push(join(workspace, 'node_modules', 'dsh-ris-ide', 'lib', 'proxy.js'))
|
|
38
|
+
list.push(join(workspace, 'work', 'dsh-ris-ide', 'lib', 'proxy.js'))
|
|
39
|
+
list.push(join(workspace, '..', 'dsh-ris-ide', 'lib', 'proxy.js'))
|
|
40
|
+
}
|
|
41
|
+
list.push(join(homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-ris-ide', 'lib', 'proxy.js'))
|
|
42
|
+
return list
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve and import the IDE's proxy engine.
|
|
47
|
+
* @returns {Promise<{available: boolean, path: string, mod: object|null, error: string}>}
|
|
48
|
+
*/
|
|
49
|
+
export async function loadIdeProxy(cfg) {
|
|
50
|
+
const explicit = process.env.YDS_IDE_PROXY
|
|
51
|
+
// `none` is the documented off switch (same effect as proxy.engine=self, but
|
|
52
|
+
// it also drops the search): tests and machines with a broken install use it.
|
|
53
|
+
if (explicit === 'none') return { available: false, path: '', mod: null, error: '已通过 YDS_IDE_PROXY=none 禁用' }
|
|
54
|
+
const tried = []
|
|
55
|
+
let lastError = ''
|
|
56
|
+
for (const candidate of ideProxyCandidates(cfg)) {
|
|
57
|
+
tried.push(candidate)
|
|
58
|
+
if (!existsSync(candidate)) continue
|
|
59
|
+
try {
|
|
60
|
+
const mod = await import(pathToFileURL(candidate).href)
|
|
61
|
+
if (typeof mod.startProxy !== 'function' || typeof mod.mergeConfigs !== 'function') {
|
|
62
|
+
lastError = `模块不完整(缺少 startProxy/mergeConfigs): ${candidate}`
|
|
63
|
+
if (candidate === explicit) break
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
return { available: true, path: candidate, mod, error: '' }
|
|
67
|
+
} catch (error) {
|
|
68
|
+
lastError = `${candidate}: ${error?.message ?? error}`
|
|
69
|
+
// An explicit override that exists but does not load is decisive: falling
|
|
70
|
+
// through to the profile install would hide the user's broken setting.
|
|
71
|
+
if (candidate === explicit) break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { available: false, path: '', mod: null, error: lastError === '' ? `未找到 dsh-ris-ide 的代理引擎(已尝试 ${tried.length} 处)` : lastError }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the IDE's ProxyBridge adapter (the system-level interceptor).
|
|
79
|
+
*
|
|
80
|
+
* It is a SIBLING module of the proxy engine (`lib/redirector.js` in the same
|
|
81
|
+
* install), so a machine with the engine always has it. Loading it is optional:
|
|
82
|
+
* the bridge then exports the same rule table as a `.pbprofile` and reports the
|
|
83
|
+
* command that would start it (starting needs root/UAC, which we never have).
|
|
84
|
+
* @param {object} cfg - resolved yds config.
|
|
85
|
+
* @returns {Promise<{available: boolean, path: string, mod: object|null}>}
|
|
86
|
+
*/
|
|
87
|
+
export async function loadIdeRedirector(cfg) {
|
|
88
|
+
const engine = await loadIdeProxy(cfg)
|
|
89
|
+
if (engine.available === false) return { available: false, path: '', mod: null }
|
|
90
|
+
const candidate = join(dirname(engine.path), 'redirector.js')
|
|
91
|
+
if (!existsSync(candidate)) return { available: false, path: candidate, mod: null }
|
|
92
|
+
try {
|
|
93
|
+
const mod = await import(pathToFileURL(candidate).href)
|
|
94
|
+
if (typeof mod.syncRedirector !== 'function' || typeof mod.redirectorStatus !== 'function') {
|
|
95
|
+
return { available: false, path: candidate, mod: null }
|
|
96
|
+
}
|
|
97
|
+
return { available: true, path: candidate, mod }
|
|
98
|
+
} catch {
|
|
99
|
+
return { available: false, path: candidate, mod: null }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Export the machine rule table as a ProxyBridge profile.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately never starts it: the process needs root (Linux eBPF) or UAC
|
|
107
|
+
* (Windows WinDivert), and a CLI that silently asked for a password in the middle
|
|
108
|
+
* of `yds start` would be worse than handing back the exact command.
|
|
109
|
+
* @param {object} cfg - resolved yds config.
|
|
110
|
+
* @returns {Promise<{available: boolean, profilePath?: string, rules?: number, command?: string, reason?: string}>}
|
|
111
|
+
*/
|
|
112
|
+
export async function syncRedirectorProfile(cfg) {
|
|
113
|
+
const loaded = await loadIdeRedirector(cfg)
|
|
114
|
+
if (loaded.available === false) return { available: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
|
|
115
|
+
const status = loaded.mod.redirectorStatus()
|
|
116
|
+
if (status.local.available !== true && status.windows.available !== true) {
|
|
117
|
+
return { available: false, reason: '未找到 ProxyBridge CLI(本侧与 Windows 侧都没有)' }
|
|
118
|
+
}
|
|
119
|
+
const result = await loaded.mod.syncRedirector(cfg?.workspace ?? '', { start: false })
|
|
120
|
+
const display = typeof result?.command === 'object' && result.command !== null ? String(result.command.display ?? '') : ''
|
|
121
|
+
return {
|
|
122
|
+
available: true,
|
|
123
|
+
profilePath: result?.written?.path ?? status.profilePath,
|
|
124
|
+
rules: result?.written?.rules ?? 0,
|
|
125
|
+
command: display,
|
|
126
|
+
reason: typeof result?.reason === 'string' ? result.reason : '',
|
|
127
|
+
running: status.running === true,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** True for a dotted-quad IPv4 literal (the only host shape a shadow can bind). */
|
|
132
|
+
export function isIpv4Literal(host) {
|
|
133
|
+
return typeof host === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether a literal IPv4 address sits in an RFC1918 range.
|
|
138
|
+
*
|
|
139
|
+
* Only such destinations need interception: a public host (the workbench, the
|
|
140
|
+
* gitlab front door) is reachable from this machine anyway, and routing it
|
|
141
|
+
* through the tunnel would only add a hop.
|
|
142
|
+
* @param {string} host - dotted-quad address.
|
|
143
|
+
* @returns {boolean} true for 10/8, 172.16/12 and 192.168/16.
|
|
144
|
+
*/
|
|
145
|
+
export function isPrivateIpv4(host) {
|
|
146
|
+
if (!isIpv4Literal(host)) return false
|
|
147
|
+
const [a, b] = host.split('.').map(Number)
|
|
148
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A path the way ProxyBridge compares it: on Windows it always sees backslashes
|
|
153
|
+
* and a drive letter, so a WSL path has to become `D:\...` before it can match
|
|
154
|
+
* anything. POSIX paths (a native Linux YonCode) are returned unchanged.
|
|
155
|
+
*/
|
|
156
|
+
export function toProcessPattern(value) {
|
|
157
|
+
if (typeof value !== 'string' || value === '') return ''
|
|
158
|
+
const mounted = /^\/mnt\/([a-z])\/(.+)$/i.exec(value)
|
|
159
|
+
if (mounted !== null) return `${mounted[1].toUpperCase()}:\\${mounted[2].replace(/\//g, '\\')}`
|
|
160
|
+
if (/^[A-Za-z]:[\\/]/.test(value) === true || value.includes('\\') === true) return value.replace(/\//g, '\\')
|
|
161
|
+
return value
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function existingDirectory(value) {
|
|
165
|
+
try {
|
|
166
|
+
return statSync(value).isDirectory()
|
|
167
|
+
} catch {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function existingFile(value) {
|
|
173
|
+
try {
|
|
174
|
+
return statSync(value).isFile()
|
|
175
|
+
} catch {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The executables YonCode itself runs: the JDK and maven it ships, plus the git it
|
|
182
|
+
* shells out to. They live outside the project, so a rule written from the project
|
|
183
|
+
* config would never name them — yet these are exactly the processes that need the
|
|
184
|
+
* tunnel to reach 172.20.235.* and gitlab.
|
|
185
|
+
*/
|
|
186
|
+
export function yoncodeProcesses(options = {}) {
|
|
187
|
+
const homes = Array.isArray(options.homes) === true ? options.homes : []
|
|
188
|
+
const out = { java: '', git: '', home: '' }
|
|
189
|
+
for (const home of homes) {
|
|
190
|
+
if (typeof home !== 'string' || home === '' || existingDirectory(home) !== true) continue
|
|
191
|
+
const devkit = join(home, 'devkit')
|
|
192
|
+
if (existingDirectory(devkit) === false) continue
|
|
193
|
+
if (out.java === '') {
|
|
194
|
+
let versions = []
|
|
195
|
+
try {
|
|
196
|
+
versions = readdirSync(devkit, { withFileTypes: true })
|
|
197
|
+
} catch {
|
|
198
|
+
versions = []
|
|
199
|
+
}
|
|
200
|
+
for (const entry of versions) {
|
|
201
|
+
if (entry.isDirectory() !== true) continue
|
|
202
|
+
// Only JDK trees: devkit also holds maven/node/etc., and a bare `*` would
|
|
203
|
+
// turn into a ProcessName that matches programs we never meant to intercept.
|
|
204
|
+
if (/^jdk/i.test(entry.name) !== true) continue
|
|
205
|
+
if (existingFile(join(devkit, entry.name, 'bin', 'java.exe')) === false) continue
|
|
206
|
+
// Wildcard the version directory: YonCode upgrades its JDK in place and the
|
|
207
|
+
// rule must survive that.
|
|
208
|
+
// `jdk*\bin\*.exe` covers java.exe/javaw.exe and survives a JDK upgrade.
|
|
209
|
+
out.java = toProcessPattern(join(devkit, 'jdk*', 'bin', '*.exe'))
|
|
210
|
+
out.home = home
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (out.git === '' && existingFile(join(devkit, 'git', 'bin', 'git.exe')) === true) {
|
|
215
|
+
out.git = toProcessPattern(join(devkit, 'git', 'bin', 'git.exe'))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (out.git === '') {
|
|
219
|
+
for (const candidate of Array.isArray(options.gitPaths) === true ? options.gitPaths : []) {
|
|
220
|
+
if (typeof candidate !== 'string' || candidate === '' || existingFile(candidate) !== true) continue
|
|
221
|
+
out.git = toProcessPattern(candidate)
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Where YonCode runs on this machine, as paths this process can stat. */
|
|
229
|
+
export function yoncodeHomes(cfg) {
|
|
230
|
+
const explicit = typeof cfg?.yoncodeHome === 'string' && cfg.yoncodeHome !== ''
|
|
231
|
+
? [cfg.yoncodeHome]
|
|
232
|
+
: yoncodeCandidates(cfg ?? {})
|
|
233
|
+
return explicit.filter((value) => existingDirectory(value) === true)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The git YonCode uses when it is not the one bundled with the devkit: the Windows
|
|
238
|
+
* installs reachable from WSL, then whatever `git` resolves to here.
|
|
239
|
+
*/
|
|
240
|
+
export function candidateGitPaths(options = {}) {
|
|
241
|
+
if (Array.isArray(options.gitPaths) === true) return options.gitPaths
|
|
242
|
+
const out = [
|
|
243
|
+
'/mnt/c/Program Files/Git/bin/git.exe',
|
|
244
|
+
'/mnt/d/Program Files/Git/bin/git.exe',
|
|
245
|
+
'/mnt/c/Program Files/Git/cmd/git.exe',
|
|
246
|
+
'/mnt/d/Program Files/Git/cmd/git.exe',
|
|
247
|
+
]
|
|
248
|
+
if (process.platform === 'win32') {
|
|
249
|
+
try {
|
|
250
|
+
const where = execFileSync('where.exe', ['git'], { encoding: 'utf8', timeout: 4000, windowsHide: true })
|
|
251
|
+
for (const line of where.split(/\r?\n/)) if (line.trim() !== '') out.unshift(line.trim())
|
|
252
|
+
} catch {
|
|
253
|
+
// Git is optional here: without it the generic `git` rule already covers PATH.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Rules that name YonCode's own executables, labelled so the dialog and the
|
|
261
|
+
* ProxyBridge profile say where they came from.
|
|
262
|
+
*
|
|
263
|
+
* `hosts` is the set of private /24 segments the backend really dials, derived from the
|
|
264
|
+
* resolved endpoints: YonCode's java must be redirected to the same place our own
|
|
265
|
+
* `java` rows point, and hardcoding any range would silently miss another environment.
|
|
266
|
+
*/
|
|
267
|
+
export function yoncodeRules(cfg, options = {}) {
|
|
268
|
+
const withJava = options.java !== false
|
|
269
|
+
const hosts = Array.isArray(options.hosts) ? options.hosts.filter((host) => String(host).trim() !== '') : []
|
|
270
|
+
const found = yoncodeProcesses({
|
|
271
|
+
homes: options.homes ?? yoncodeHomes(cfg),
|
|
272
|
+
gitPaths: options.gitPaths ?? candidateGitPaths(options),
|
|
273
|
+
})
|
|
274
|
+
const workspace = options.workspace ?? cfg?.workspace ?? ''
|
|
275
|
+
const gitHost = options.gitHost ?? ''
|
|
276
|
+
const rules = []
|
|
277
|
+
if (withJava && found.java !== '' && hosts.length > 0) {
|
|
278
|
+
rules.push({
|
|
279
|
+
id: 'yds-yoncode-java',
|
|
280
|
+
label: 'YonCode java',
|
|
281
|
+
program: found.java,
|
|
282
|
+
hosts: hosts.join(','),
|
|
283
|
+
ports: '*',
|
|
284
|
+
enabled: true,
|
|
285
|
+
shadow: false,
|
|
286
|
+
source: YDS_RULE_SOURCE,
|
|
287
|
+
workspace,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (found.git !== '' && gitHost !== '') {
|
|
291
|
+
rules.push({
|
|
292
|
+
id: 'yds-yoncode-git',
|
|
293
|
+
label: 'YonCode git',
|
|
294
|
+
program: found.git,
|
|
295
|
+
hosts: gitHost,
|
|
296
|
+
ports: '*',
|
|
297
|
+
enabled: true,
|
|
298
|
+
shadow: false,
|
|
299
|
+
source: YDS_RULE_SOURCE,
|
|
300
|
+
workspace,
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
return rules
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The fallback loopback listeners, derived from the same endpoint list.
|
|
308
|
+
*
|
|
309
|
+
* With ProxyBridge down a JDBC/Redis client inside the JVM cannot be told to use
|
|
310
|
+
* SOCKS, so the tunnel is exposed on `127.0.0.1:<same port>` and the adapted config
|
|
311
|
+
* points there instead. These are ENGINE INPUTS, not rule rows: the dialog shows
|
|
312
|
+
* intent (which programs are proxied), never this plumbing.
|
|
313
|
+
* @param {Array<{host: string, port: number}>} targets - endpoints from the config adapter.
|
|
314
|
+
* @returns {Array<{host: string, port: number}>} one listener per literal-IPv4 endpoint.
|
|
315
|
+
*/
|
|
316
|
+
export function shadowTargetsFromTargets(targets) {
|
|
317
|
+
const out = []
|
|
318
|
+
const seen = new Set()
|
|
319
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
320
|
+
const host = String(target?.host ?? '')
|
|
321
|
+
const port = Number(target?.port)
|
|
322
|
+
if (!isIpv4Literal(host) || !Number.isInteger(port) || port < 1 || port > 65535) continue
|
|
323
|
+
const key = `${host}:${port}`
|
|
324
|
+
if (seen.has(key)) continue
|
|
325
|
+
seen.add(key)
|
|
326
|
+
out.push({ host, port })
|
|
327
|
+
}
|
|
328
|
+
return out
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Rules this project contributes to the machine-wide table.
|
|
333
|
+
*
|
|
334
|
+
* Two rows, both naming a real process: `java` for the internal segment (the JVM
|
|
335
|
+
* is the client of every JDBC/Redis/ES/license endpoint) and `git` for its remote.
|
|
336
|
+
* Endpoints are deliberately NOT rows: a per-endpoint row would have to invent a
|
|
337
|
+
* program name (the old `jdbc` label matched no process at all) and would repeat
|
|
338
|
+
* what the /24 rule already covers.
|
|
339
|
+
* @param {Array<{host: string, port: number}>} targets - endpoints from the config adapter.
|
|
340
|
+
* @param {string} [workspace] - provenance label, not part of the rule identity.
|
|
341
|
+
* @param {{gitHost?: string}} [options] - host of the git remote, when known.
|
|
342
|
+
* @returns {object[]} rule rows in the engine's schema.
|
|
343
|
+
*/
|
|
344
|
+
// Addresses the backend must reach that are not database/redis endpoints: the
|
|
345
|
+
// bind/callback host (relayed back to this machine) and anything the project
|
|
346
|
+
// settings name explicitly. Same rule as the endpoints: private ranges only.
|
|
347
|
+
export function extraHostsOf(cfg) {
|
|
348
|
+
const hosts = []
|
|
349
|
+
const push = (value) => {
|
|
350
|
+
const text = String(value ?? '').trim()
|
|
351
|
+
if (text === '') return
|
|
352
|
+
const host = text.includes(':') ? text.slice(0, text.lastIndexOf(':')) : text
|
|
353
|
+
if (hosts.includes(host) !== true) hosts.push(host)
|
|
354
|
+
}
|
|
355
|
+
push(cfg?.serverBindAddress)
|
|
356
|
+
return hosts
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Private /24 segments (as `1.2.3.*` patterns) for rows that name an executable
|
|
361
|
+
* instead of a service: the java process calls the same databases/redis as the app.
|
|
362
|
+
*/
|
|
363
|
+
export function privateSegmentsOf(targets, extraHosts = []) {
|
|
364
|
+
const segments = new Set()
|
|
365
|
+
const add = (host) => {
|
|
366
|
+
const text = String(host ?? '').trim()
|
|
367
|
+
if (isPrivateIpv4(text) !== true) return
|
|
368
|
+
segments.add(`${text.split('.').slice(0, 3).join('.')}.*`)
|
|
369
|
+
}
|
|
370
|
+
for (const target of Array.isArray(targets) ? targets : []) add(target?.host)
|
|
371
|
+
for (const host of Array.isArray(extraHosts) ? extraHosts : []) add(host)
|
|
372
|
+
return [...segments].sort()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// `java` defaults to false on purpose: config.js's `proxy.javaRules` also defaults off
|
|
376
|
+
// (the YMS JVM gets -DsocksProxyHost from `yds yoncode patch`, and WinDivert resets the
|
|
377
|
+
// Oracle TNS handshake). Both entry points must agree, or a direct helper call silently
|
|
378
|
+
// turns java interception back on.
|
|
379
|
+
export function proxyRulesFromTargets(targets, workspace = '', { gitHost = '', java = false, extraHosts = [] } = {}) {
|
|
380
|
+
const rules = []
|
|
381
|
+
const segments = new Set()
|
|
382
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
383
|
+
const host = String(target?.host ?? '')
|
|
384
|
+
const port = Number(target?.port)
|
|
385
|
+
if (host === '' || !Number.isInteger(port) || port <= 0 || port > 65535) continue
|
|
386
|
+
if (isPrivateIpv4(host)) segments.add(host.split('.').slice(0, 3).join('.'))
|
|
387
|
+
}
|
|
388
|
+
// Hosts the app itself has to dial that the bootstrap never lists: the callback /
|
|
389
|
+
// bind address (the reverse-proxied machine that relays gateway traffic back here)
|
|
390
|
+
// is such a case — Java cannot reach it without the tunnel either.
|
|
391
|
+
for (const host of Array.isArray(extraHosts) ? extraHosts : []) {
|
|
392
|
+
const value = String(host ?? '').trim()
|
|
393
|
+
if (isPrivateIpv4(value) !== true) continue
|
|
394
|
+
segments.add(value.split('.').slice(0, 3).join('.'))
|
|
395
|
+
}
|
|
396
|
+
// One rule per internal segment and ANY port: the backend also talks to hosts the
|
|
397
|
+
// bootstrap never lists (license, ES, kafka), so a port list would go stale.
|
|
398
|
+
for (const segment of java ? Array.from(segments).sort() : []) {
|
|
399
|
+
rules.push({
|
|
400
|
+
id: `yds-java-${segment.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`,
|
|
401
|
+
program: 'java',
|
|
402
|
+
hosts: `${segment}.*`,
|
|
403
|
+
ports: '*',
|
|
404
|
+
enabled: true,
|
|
405
|
+
shadow: false,
|
|
406
|
+
source: YDS_RULE_SOURCE,
|
|
407
|
+
workspace,
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
// git reaches its remote through the same tunnel; `git config http.<host>.proxy`
|
|
411
|
+
// stays as the belt for terminals that do not go through ProxyBridge.
|
|
412
|
+
if (gitHost !== '') {
|
|
413
|
+
rules.push({
|
|
414
|
+
id: 'yds-git',
|
|
415
|
+
program: 'git',
|
|
416
|
+
hosts: gitHost,
|
|
417
|
+
ports: '*',
|
|
418
|
+
enabled: true,
|
|
419
|
+
shadow: false,
|
|
420
|
+
source: YDS_RULE_SOURCE,
|
|
421
|
+
workspace,
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
return rules
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The git host from the configured clone URL, or `''` when it has none. */
|
|
428
|
+
export function gitHostOf(cfg) {
|
|
429
|
+
const url = typeof cfg?.git?.url === 'string' ? cfg.git.url : ''
|
|
430
|
+
if (url === '') return ''
|
|
431
|
+
try { return new URL(url).host } catch { return '' }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Merge the YDS-owned rules into the IDE's proxy config and write it back.
|
|
436
|
+
* @returns {{config: object, rules: object[]}} the merged config.
|
|
437
|
+
*/
|
|
438
|
+
export function syncProxyConfig(cfg, targets, { log, mapping = true, java = cfg?.proxyJavaRules === true } = {}) {
|
|
439
|
+
const loaded = cfg.__ideProxy
|
|
440
|
+
if (loaded === undefined || loaded === null || loaded.available !== true) throw new Error('代理引擎未加载')
|
|
441
|
+
const mod = loaded.mod
|
|
442
|
+
const rules = [
|
|
443
|
+
...proxyRulesFromTargets(targets, cfg.workspace ?? '', { gitHost: gitHostOf(cfg), java, extraHosts: extraHostsOf(cfg) }),
|
|
444
|
+
// YonCode runs its own JDK/git from outside the project; without these rows the
|
|
445
|
+
// IDE's java would only be covered by the generic `java` pattern.
|
|
446
|
+
...yoncodeRules(cfg, {
|
|
447
|
+
gitHost: gitHostOf(cfg),
|
|
448
|
+
workspace: cfg.workspace ?? '',
|
|
449
|
+
java,
|
|
450
|
+
hosts: privateSegmentsOf(targets, extraHostsOf(cfg)),
|
|
451
|
+
}),
|
|
452
|
+
]
|
|
453
|
+
// Bound only while the system layer is unavailable; in interception mode this is
|
|
454
|
+
// empty, so nothing is bound on loopback at all.
|
|
455
|
+
const shadows = mapping === false ? [] : shadowTargetsFromTargets(targets)
|
|
456
|
+
const stored = mod.readProxyConfig(cfg.workspace)
|
|
457
|
+
const upstream = cfg.socks ?? null
|
|
458
|
+
const merged = mod.mergeConfigs(stored, {
|
|
459
|
+
socks: upstream === null ? { host: '', port: 0 } : { host: upstream.host, port: upstream.port },
|
|
460
|
+
listenPort: cfg.proxyListenPort ?? stored.listenPort,
|
|
461
|
+
// Replacing wholesale is the whole point: rules this plugin wrote last run
|
|
462
|
+
// must disappear when the endpoint list shrinks, while user rows survive.
|
|
463
|
+
replaceRulesFrom: YDS_RULE_SOURCE,
|
|
464
|
+
shadows,
|
|
465
|
+
// The config file is machine-wide now: only this workspace's yds rows are
|
|
466
|
+
// replaced, other workspaces keep theirs.
|
|
467
|
+
workspace: cfg.workspace ?? '',
|
|
468
|
+
rules,
|
|
469
|
+
})
|
|
470
|
+
mod.writeProxyConfig(cfg.workspace, merged)
|
|
471
|
+
log?.(`代理规则已同步:${rules.length} 条(source=${YDS_RULE_SOURCE})`)
|
|
472
|
+
return { config: merged, rules }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Run one `git config --global` mutation; a missing git is a reported no-op. */
|
|
476
|
+
function runGitConfig(args) {
|
|
477
|
+
return new Promise((resolvePromise) => {
|
|
478
|
+
const child = spawn('git', ['config', '--global', ...args], { windowsHide: true })
|
|
479
|
+
let stderr = ''
|
|
480
|
+
let settled = false
|
|
481
|
+
const done = (result) => { if (!settled) { settled = true; resolvePromise(result) } }
|
|
482
|
+
child.stderr?.on('data', (chunk) => { if (stderr.length < 4096) stderr += chunk.toString('utf8') })
|
|
483
|
+
child.on('error', (error) => done({ ok: false, error: String(error?.message ?? error) }))
|
|
484
|
+
child.on('close', (code) => done(code === 0 ? { ok: true } : { ok: false, error: stderr.trim() || `git exited with ${code}` }))
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Write (or clear) the scoped git proxy entry, so EVERY git invocation honours
|
|
490
|
+
* SOCKS — including dsh-git's own commands, which never get `-c http.proxy`.
|
|
491
|
+
*
|
|
492
|
+
* The key is scoped to the configured clone host (`http.https://<host>/.proxy`)
|
|
493
|
+
* rather than the global `http.proxy`: a user's other remotes must not silently
|
|
494
|
+
* start going through this tunnel.
|
|
495
|
+
* @returns {Promise<{ok: boolean, key: string, value?: string, error?: string}>}
|
|
496
|
+
*/
|
|
497
|
+
/**
|
|
498
|
+
* Persist (or forget) "this git proxy entry is ours" for the exit hook.
|
|
499
|
+
*
|
|
500
|
+
* dsh-ris-ide owns the file (`~/.dsh-ris-ide/git-proxy.json`) and its unset is
|
|
501
|
+
* keyed on exactly this record; writing it from the CLI path means a `yds stop`
|
|
502
|
+
* — or simply closing dsh — undoes the entry no matter which side wrote it.
|
|
503
|
+
* @param {object} mod - the loaded dsh-ris-ide proxy module.
|
|
504
|
+
* @param {{key: string, host: string}|null} state - null clears the record.
|
|
505
|
+
*/
|
|
506
|
+
function recordGitProxyState(mod, state) {
|
|
507
|
+
try {
|
|
508
|
+
if (typeof mod?.proxyHomeDir !== 'function') return
|
|
509
|
+
const dir = mod.proxyHomeDir()
|
|
510
|
+
const path = join(dir, 'git-proxy.json')
|
|
511
|
+
if (state === null) {
|
|
512
|
+
rmSync(path, { force: true })
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
mkdirSync(dir, { recursive: true })
|
|
516
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
|
|
517
|
+
} catch {
|
|
518
|
+
// A missing state file only costs the exit-hook undo; never fail the command.
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function applyGitProxy(cfg, { enabled = true } = {}) {
|
|
523
|
+
const loaded = cfg.__ideProxy
|
|
524
|
+
if (loaded === undefined || loaded === null || loaded.available !== true) return { ok: false, key: '', error: '代理引擎未加载' }
|
|
525
|
+
const host = gitHostOf(cfg)
|
|
526
|
+
if (host === '' && enabled) return { ok: false, key: '', error: '未配置 git.url,无法确定要写哪一条 git 代理' }
|
|
527
|
+
const plan = loaded.mod.gitProxyPlan(loaded.mod.readProxyConfig(cfg.workspace), { host, enabled })
|
|
528
|
+
const result = enabled
|
|
529
|
+
? await runGitConfig([plan.key, plan.value])
|
|
530
|
+
: await runGitConfig(['--unset', plan.key])
|
|
531
|
+
// The Windows-side git has its own config file and cannot resolve the internal
|
|
532
|
+
// host, so the same scoped entry has to be written there too — YonCode runs git on
|
|
533
|
+
// that side of the boundary.
|
|
534
|
+
const windows = typeof loaded.mod.runWindowsGitConfig === 'function'
|
|
535
|
+
? loaded.mod.runWindowsGitConfig(enabled ? [plan.key, plan.value] : ['--unset', plan.key])
|
|
536
|
+
: { ok: false, skipped: true, error: '引擎版本过旧,不支持 Windows 侧 git' }
|
|
537
|
+
// Record it where dsh-ris-ide keeps the same fact: its exit hook then clears a
|
|
538
|
+
// tunnel we wrote from the CLI, so a dead tunnel never leaves git pointed at it.
|
|
539
|
+
recordGitProxyState(loaded.mod, enabled ? { key: plan.key, host, windows: windows.ok === true } : null)
|
|
540
|
+
return Object.assign({ key: plan.key, value: enabled ? plan.value : '', windows }, result)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Make sure the IDE engine runs the current YDS rules.
|
|
545
|
+
*
|
|
546
|
+
* A changed rule set needs a restart: the engine binds its shadows at startup and
|
|
547
|
+
* never re-reads the file (that is also why the dialog says "restart to apply").
|
|
548
|
+
* @returns {Promise<{mode: string, ok?: boolean, error?: string, path?: string,
|
|
549
|
+
* rules?: object[], shadowPorts?: number[], restarted?: boolean}>}
|
|
550
|
+
*/
|
|
551
|
+
/**
|
|
552
|
+
* Start the system-level interception that pairs with the tunnel.
|
|
553
|
+
*
|
|
554
|
+
* The engine only covers programs we spawn, so the redirector is what makes an
|
|
555
|
+
* IDE-launched java or a terminal git go through the tunnel at all. Elevation is
|
|
556
|
+
* the normal case: a Windows side triggers UAC, a Linux side needs passwordless
|
|
557
|
+
* sudo — a side that cannot be elevated is reported, never fatal.
|
|
558
|
+
* @param {object} cfg - resolved plugin config.
|
|
559
|
+
* @param {{log?: Function}} [options] - logger.
|
|
560
|
+
* @returns {Promise<{available: boolean, started: object[], skipped: object[], running: boolean}>} outcome.
|
|
561
|
+
*/
|
|
562
|
+
export async function startBorrowedRedirector(cfg, { log, sides } = {}) {
|
|
563
|
+
const loaded = await loadIdeRedirector(cfg)
|
|
564
|
+
if (loaded.available === false) return { available: false, started: [], skipped: [], running: false }
|
|
565
|
+
if (typeof loaded.mod.startRedirectors !== 'function') return { available: false, started: [], skipped: [], running: false }
|
|
566
|
+
// `reload: true` makes an already-running bridge restart with the profile we just
|
|
567
|
+
// wrote — edited yds rules must apply without a manual stop/start (or a new UAC).
|
|
568
|
+
const result = await loaded.mod.startRedirectors(Array.isArray(sides) ? { sides, reload: true } : { reload: true })
|
|
569
|
+
const started = Array.isArray(result?.started) ? result.started : []
|
|
570
|
+
const skipped = Array.isArray(result?.skipped) ? result.skipped : []
|
|
571
|
+
if (started.length > 0) {
|
|
572
|
+
const reloaded = started.filter((item) => item.reloaded === true)
|
|
573
|
+
const fresh = started.filter((item) => item.reloaded !== true)
|
|
574
|
+
const parts = []
|
|
575
|
+
if (fresh.length > 0) parts.push(`已启动(${fresh.map((item) => `${item.side} pid ${item.pid ?? '?'}`).join(',')})`)
|
|
576
|
+
if (reloaded.length > 0) parts.push(`已按新规则重载(${reloaded.map((item) => item.side).join(',')})`)
|
|
577
|
+
log?.(`系统层拦截${parts.join(';')}`)
|
|
578
|
+
}
|
|
579
|
+
else if (skipped.length > 0 && typeof skipped[0]?.reason === 'string') log?.(`系统层拦截未启动:${skipped[0].reason}`)
|
|
580
|
+
return { available: true, started, skipped, running: result?.running === true }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Stop the redirector and clear the git proxy that were started with the tunnel.
|
|
585
|
+
*
|
|
586
|
+
* Both are machine-scoped side effects of `yds start`, so `yds stop` removes them
|
|
587
|
+
* even if this run did not create them — a leftover redirector keeps routing
|
|
588
|
+
* traffic with no UI, and a leftover git proxy breaks `git` once the tunnel dies.
|
|
589
|
+
* @param {object} cfg - resolved plugin config.
|
|
590
|
+
* @param {{log?: Function, keepGit?: boolean}} [options] - logger / skip git undo.
|
|
591
|
+
* @returns {Promise<{redirector: object, git: object}>} per-layer outcome.
|
|
592
|
+
*/
|
|
593
|
+
export async function stopBorrowedSideEffects(cfg, { log, keepGit = false } = {}) {
|
|
594
|
+
const loaded = await loadIdeRedirector(cfg)
|
|
595
|
+
let redirector = { stopped: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
|
|
596
|
+
if (loaded.available === true && typeof loaded.mod.stopRedirector === 'function') {
|
|
597
|
+
redirector = await loaded.mod.stopRedirector()
|
|
598
|
+
if (redirector.stopped === true) log?.(`系统层拦截已停止(pid ${redirector.pid ?? '?'})`)
|
|
599
|
+
else if (typeof redirector.reason === 'string' && redirector.reason !== '' && redirector.reason !== '未在运行') {
|
|
600
|
+
log?.(`系统层拦截未停止:${redirector.reason}`)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
let git = { ok: true, key: '' }
|
|
604
|
+
if (keepGit !== true) git = await applyGitProxy(cfg, { enabled: false })
|
|
605
|
+
return { redirector, git }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Write this project's rules into the shared table WITHOUT starting an engine.
|
|
610
|
+
*
|
|
611
|
+
* Interception mode needs the rules (the ProxyBridge profile is built from them)
|
|
612
|
+
* but must not bind anything: the redirector already carries the connections.
|
|
613
|
+
* @param {object} cfg - resolved plugin config.
|
|
614
|
+
* @param {object[]} targets - endpoints collected from the config.
|
|
615
|
+
* @param {{log?: Function}} [options] - logger.
|
|
616
|
+
* @returns {Promise<{mode: string, rules?: object[]}>} sync outcome.
|
|
617
|
+
*/
|
|
618
|
+
export async function syncBorrowedRules(cfg, targets, { log } = {}) {
|
|
619
|
+
const loaded = await loadIdeProxy(cfg)
|
|
620
|
+
cfg.__ideProxy = loaded
|
|
621
|
+
if (!loaded.available) return { mode: 'unavailable' }
|
|
622
|
+
const { rules } = syncProxyConfig(cfg, targets, { log, mapping: false })
|
|
623
|
+
log?.('代理规则已写入(系统层拦截模式,无 127.0.0.1 映射)')
|
|
624
|
+
return { mode: 'ide', rules }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export async function ensureBorrowedProxy(cfg, targets, { log, mapping = true } = {}) {
|
|
628
|
+
// Direct mode (no `.env` SOCKS settings) has nothing to forward through: the
|
|
629
|
+
// config's own addresses are reachable, so no engine is started at all.
|
|
630
|
+
if (cfg.socks === null || cfg.socks === undefined) return { mode: 'none' }
|
|
631
|
+
if (Array.isArray(targets) && targets.length === 0) {
|
|
632
|
+
log?.('代理引擎:bootstrap 没有需要转发的内网端点,跳过')
|
|
633
|
+
return { mode: 'none' }
|
|
634
|
+
}
|
|
635
|
+
if (cfg.proxyEngine === 'self') { log?.('代理引擎:按 .env 配置使用内置转发器(proxy.engine=self)'); return { mode: 'unavailable', error: 'disabled by proxy.engine=self' } }
|
|
636
|
+
const loaded = await loadIdeProxy(cfg)
|
|
637
|
+
cfg.__ideProxy = loaded
|
|
638
|
+
if (!loaded.available) {
|
|
639
|
+
// Standalone by design: the built-in forwarder is a complete fallback, so
|
|
640
|
+
// the sibling plugin is only ever a recommendation (one shared engine).
|
|
641
|
+
log?.(`代理引擎:未找到 dsh-ris-ide(${loaded.error}),回退内置转发器`
|
|
642
|
+
+ '(可选:装上 dsh-ris-ide 可在同一个 dsh 里获得 IDE 面板与服务调试能力)')
|
|
643
|
+
return { mode: 'unavailable', error: loaded.error }
|
|
644
|
+
}
|
|
645
|
+
const mod = loaded.mod
|
|
646
|
+
const before = mod.readProxyConfig(cfg.workspace)
|
|
647
|
+
const { config, rules } = syncProxyConfig(cfg, targets, { log, mapping })
|
|
648
|
+
const running = mod.proxyPid(cfg.workspace) !== null
|
|
649
|
+
// A running engine holds the OLD rules; only a restart applies the new ones.
|
|
650
|
+
const changed = JSON.stringify(before.rules) !== JSON.stringify(config.rules)
|
|
651
|
+
let restarted = false
|
|
652
|
+
if (running && changed) {
|
|
653
|
+
log?.('代理规则变化,重启代理引擎…')
|
|
654
|
+
await mod.stopProxy(cfg.workspace)
|
|
655
|
+
restarted = true
|
|
656
|
+
}
|
|
657
|
+
const started = await mod.startProxy(cfg.workspace)
|
|
658
|
+
if (started.ok !== true) return { mode: 'ide', ok: false, error: started.error ?? '代理引擎启动失败', path: loaded.path, rules }
|
|
659
|
+
// The loopback listeners are a config field now, not rule rows: read them back
|
|
660
|
+
// from the file the engine just loaded instead of guessing from the rules.
|
|
661
|
+
const shadowPorts = (mod.readProxyConfig(cfg.workspace)?.shadows ?? []).map((entry) => entry.localPort)
|
|
662
|
+
const shadowNote = shadowPorts.length > 0
|
|
663
|
+
? `,127.0.0.1 兜底映射 ${shadowPorts.length} 个端口(ProxyBridge 未运行时的后备)`
|
|
664
|
+
: ',无 127.0.0.1 兜底映射(系统层拦截生效)'
|
|
665
|
+
log?.(`代理引擎:dsh-ris-ide 运行中(pid ${started.pid ?? '?'})${shadowNote}${restarted ? '(已重启)' : ''}`)
|
|
666
|
+
return { mode: 'ide', ok: true, path: loaded.path, pid: started.pid ?? null, rules, shadowPorts, restarted }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Stop the borrowed engine (no-op when dsh-ris-ide is not installed). */
|
|
670
|
+
export async function stopBorrowedProxy(cfg) {
|
|
671
|
+
const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
|
|
672
|
+
if (loaded.available !== true) return { ok: false, error: loaded.error }
|
|
673
|
+
return loaded.mod.stopProxy(cfg.workspace)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Engine status for `yds status` / `yds proxy status`. */
|
|
677
|
+
export async function borrowedProxyStatus(cfg) {
|
|
678
|
+
const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
|
|
679
|
+
if (loaded.available !== true) return { available: false, error: loaded.error }
|
|
680
|
+
const status = await loaded.mod.proxyStatus(cfg.workspace)
|
|
681
|
+
return { available: true, path: loaded.path, running: status.running === true, pid: status.pid ?? null, listenPort: status.config?.listenPort ?? null, rules: status.config?.rules ?? [] }
|
|
682
|
+
}
|