dsh-ris-yds 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +160 -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 +1130 -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 +906 -0
- package/lib/ide-watch.js +188 -0
- package/lib/ide.js +728 -0
- package/lib/index.js +267 -0
- package/lib/proc.js +270 -0
- package/lib/risd-peer.js +353 -0
- package/lib/rules.js +193 -0
- package/lib/socks.js +279 -0
- package/lib/token.js +36 -0
- package/lib/yms.js +336 -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-stop.test.mjs +40 -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/hints.test.mjs +13 -0
- package/test/ide-config.test.mjs +155 -0
- package/test/ide-proxy.test.mjs +256 -0
- package/test/ide-watch.test.mjs +79 -0
- package/test/launch-proxy.test.mjs +83 -0
- package/test/lease-release.test.mjs +76 -0
- package/test/mode-separation.test.mjs +147 -0
- package/test/pidfile.test.mjs +105 -0
- package/test/proc.test.mjs +172 -0
- package/test/proxy-standalone.test.mjs +121 -0
- package/test/risd-leases.test.mjs +111 -0
- package/test/risd-tier.test.mjs +361 -0
- package/test/rules-e2e.test.mjs +122 -0
- package/test/rules.test.mjs +154 -0
- package/test/yoncode.test.mjs +159 -0
package/lib/ide-proxy.js
ADDED
|
@@ -0,0 +1,906 @@
|
|
|
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
|
+
import { acquireLease, dropDeadLease, engineStatus, forgetLease, getView, probeRisd, putRules, readLease, releaseLease, rememberLease, startEngine } from './risd-peer.js'
|
|
25
|
+
import { YDS_RULE_SOURCE, mergeYdsRules, programKeyOf, withdrawnBetween } from './rules.js'
|
|
26
|
+
|
|
27
|
+
/** Marker `source` used for every rule this plugin owns. */
|
|
28
|
+
// `YDS_RULE_SOURCE` now lives in ./rules.js (shared with risd-peer).
|
|
29
|
+
export { YDS_RULE_SOURCE, programKeyOf } from './rules.js'
|
|
30
|
+
|
|
31
|
+
/** Where a dsh-ris-ide checkout/install may keep its proxy engine. */
|
|
32
|
+
export function ideProxyCandidates(cfg) {
|
|
33
|
+
const list = []
|
|
34
|
+
const explicit = process.env.YDS_IDE_PROXY
|
|
35
|
+
if (typeof explicit === 'string' && explicit !== '') list.push(explicit)
|
|
36
|
+
const workspace = typeof cfg?.workspace === 'string' ? cfg.workspace : ''
|
|
37
|
+
if (workspace !== '') {
|
|
38
|
+
// A link-installed plugin lives in the profile's node_modules; a workspace
|
|
39
|
+
// install (or a sibling checkout) is the other common layout.
|
|
40
|
+
list.push(join(workspace, 'node_modules', 'dsh-ris-ide', 'lib', 'proxy.js'))
|
|
41
|
+
list.push(join(workspace, 'work', 'dsh-ris-ide', 'lib', 'proxy.js'))
|
|
42
|
+
list.push(join(workspace, '..', 'dsh-ris-ide', 'lib', 'proxy.js'))
|
|
43
|
+
}
|
|
44
|
+
list.push(join(homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-ris-ide', 'lib', 'proxy.js'))
|
|
45
|
+
return list
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve and import the IDE's proxy engine.
|
|
50
|
+
* @returns {Promise<{available: boolean, path: string, mod: object|null, error: string}>}
|
|
51
|
+
*/
|
|
52
|
+
export async function loadIdeProxy(cfg) {
|
|
53
|
+
const explicit = process.env.YDS_IDE_PROXY
|
|
54
|
+
// `none` is the documented off switch (same effect as proxy.engine=self, but
|
|
55
|
+
// it also drops the search): tests and machines with a broken install use it.
|
|
56
|
+
if (explicit === 'none') return { available: false, path: '', mod: null, error: '已通过 YDS_IDE_PROXY=none 禁用' }
|
|
57
|
+
const tried = []
|
|
58
|
+
let lastError = ''
|
|
59
|
+
for (const candidate of ideProxyCandidates(cfg)) {
|
|
60
|
+
tried.push(candidate)
|
|
61
|
+
if (!existsSync(candidate)) continue
|
|
62
|
+
try {
|
|
63
|
+
const mod = await import(pathToFileURL(candidate).href)
|
|
64
|
+
if (typeof mod.startProxy !== 'function' || typeof mod.mergeConfigs !== 'function') {
|
|
65
|
+
lastError = `模块不完整(缺少 startProxy/mergeConfigs): ${candidate}`
|
|
66
|
+
if (candidate === explicit) break
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
return { available: true, path: candidate, mod, error: '' }
|
|
70
|
+
} catch (error) {
|
|
71
|
+
lastError = `${candidate}: ${error?.message ?? error}`
|
|
72
|
+
// An explicit override that exists but does not load is decisive: falling
|
|
73
|
+
// through to the profile install would hide the user's broken setting.
|
|
74
|
+
if (candidate === explicit) break
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { available: false, path: '', mod: null, error: lastError === '' ? `未找到 dsh-ris-ide 的代理引擎(已尝试 ${tried.length} 处)` : lastError }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the IDE's ProxyBridge adapter (the system-level interceptor).
|
|
82
|
+
*
|
|
83
|
+
* It is a SIBLING module of the proxy engine (`lib/redirector.js` in the same
|
|
84
|
+
* install), so a machine with the engine always has it. Loading it is optional:
|
|
85
|
+
* the bridge then exports the same rule table as a `.pbprofile` and reports the
|
|
86
|
+
* command that would start it (starting needs root/UAC, which we never have).
|
|
87
|
+
* @param {object} cfg - resolved yds config.
|
|
88
|
+
* @returns {Promise<{available: boolean, path: string, mod: object|null}>}
|
|
89
|
+
*/
|
|
90
|
+
export async function loadIdeRedirector(cfg) {
|
|
91
|
+
const engine = await loadIdeProxy(cfg)
|
|
92
|
+
if (engine.available === false) return { available: false, path: '', mod: null }
|
|
93
|
+
const candidate = join(dirname(engine.path), 'redirector.js')
|
|
94
|
+
if (!existsSync(candidate)) return { available: false, path: candidate, mod: null }
|
|
95
|
+
try {
|
|
96
|
+
const mod = await import(pathToFileURL(candidate).href)
|
|
97
|
+
if (typeof mod.syncRedirector !== 'function' || typeof mod.redirectorStatus !== 'function') {
|
|
98
|
+
return { available: false, path: candidate, mod: null }
|
|
99
|
+
}
|
|
100
|
+
return { available: true, path: candidate, mod }
|
|
101
|
+
} catch {
|
|
102
|
+
return { available: false, path: candidate, mod: null }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Export the machine rule table as a ProxyBridge profile.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately never starts it: the process needs root (Linux eBPF) or UAC
|
|
110
|
+
* (Windows WinDivert), and a CLI that silently asked for a password in the middle
|
|
111
|
+
* of `yds start` would be worse than handing back the exact command.
|
|
112
|
+
* @param {object} cfg - resolved yds config.
|
|
113
|
+
* @returns {Promise<{available: boolean, profilePath?: string, rules?: number, command?: string, reason?: string}>}
|
|
114
|
+
*/
|
|
115
|
+
export async function syncRedirectorProfile(cfg) {
|
|
116
|
+
const loaded = await loadIdeRedirector(cfg)
|
|
117
|
+
if (loaded.available === false) return { available: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
|
|
118
|
+
const status = loaded.mod.redirectorStatus()
|
|
119
|
+
if (status.local.available !== true && status.windows.available !== true) {
|
|
120
|
+
return { available: false, reason: '未找到 ProxyBridge CLI(本侧与 Windows 侧都没有)' }
|
|
121
|
+
}
|
|
122
|
+
const result = await loaded.mod.syncRedirector(cfg?.workspace ?? '', { start: false })
|
|
123
|
+
const display = typeof result?.command === 'object' && result.command !== null ? String(result.command.display ?? '') : ''
|
|
124
|
+
return {
|
|
125
|
+
available: true,
|
|
126
|
+
profilePath: result?.written?.path ?? status.profilePath,
|
|
127
|
+
rules: result?.written?.rules ?? 0,
|
|
128
|
+
command: display,
|
|
129
|
+
reason: typeof result?.reason === 'string' ? result.reason : '',
|
|
130
|
+
running: status.running === true,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True for a dotted-quad IPv4 literal (the only host shape a shadow can bind). */
|
|
135
|
+
export function isIpv4Literal(host) {
|
|
136
|
+
return typeof host === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a literal IPv4 address sits in an RFC1918 range.
|
|
141
|
+
*
|
|
142
|
+
* Only such destinations need interception: a public host (the workbench, the
|
|
143
|
+
* gitlab front door) is reachable from this machine anyway, and routing it
|
|
144
|
+
* through the tunnel would only add a hop.
|
|
145
|
+
* @param {string} host - dotted-quad address.
|
|
146
|
+
* @returns {boolean} true for 10/8, 172.16/12 and 192.168/16.
|
|
147
|
+
*/
|
|
148
|
+
export function isPrivateIpv4(host) {
|
|
149
|
+
if (!isIpv4Literal(host)) return false
|
|
150
|
+
const [a, b] = host.split('.').map(Number)
|
|
151
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A path the way ProxyBridge compares it: on Windows it always sees backslashes
|
|
156
|
+
* and a drive letter, so a WSL path has to become `D:\...` before it can match
|
|
157
|
+
* anything. POSIX paths (a native Linux YonCode) are returned unchanged.
|
|
158
|
+
*/
|
|
159
|
+
export function toProcessPattern(value) {
|
|
160
|
+
if (typeof value !== 'string' || value === '') return ''
|
|
161
|
+
const mounted = /^\/mnt\/([a-z])\/(.+)$/i.exec(value)
|
|
162
|
+
if (mounted !== null) return `${mounted[1].toUpperCase()}:\\${mounted[2].replace(/\//g, '\\')}`
|
|
163
|
+
if (/^[A-Za-z]:[\\/]/.test(value) === true || value.includes('\\') === true) return value.replace(/\//g, '\\')
|
|
164
|
+
return value
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function existingDirectory(value) {
|
|
168
|
+
try {
|
|
169
|
+
return statSync(value).isDirectory()
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function existingFile(value) {
|
|
176
|
+
try {
|
|
177
|
+
return statSync(value).isFile()
|
|
178
|
+
} catch {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The executables YonCode itself runs: the JDK and maven it ships, plus the git it
|
|
185
|
+
* shells out to. They live outside the project, so a rule written from the project
|
|
186
|
+
* config would never name them — yet these are exactly the processes that need the
|
|
187
|
+
* tunnel to reach 172.20.235.* and gitlab.
|
|
188
|
+
*/
|
|
189
|
+
export function yoncodeProcesses(options = {}) {
|
|
190
|
+
const homes = Array.isArray(options.homes) === true ? options.homes : []
|
|
191
|
+
const out = { java: '', git: '', home: '' }
|
|
192
|
+
for (const home of homes) {
|
|
193
|
+
if (typeof home !== 'string' || home === '' || existingDirectory(home) !== true) continue
|
|
194
|
+
const devkit = join(home, 'devkit')
|
|
195
|
+
if (existingDirectory(devkit) === false) continue
|
|
196
|
+
if (out.java === '') {
|
|
197
|
+
let versions = []
|
|
198
|
+
try {
|
|
199
|
+
versions = readdirSync(devkit, { withFileTypes: true })
|
|
200
|
+
} catch {
|
|
201
|
+
versions = []
|
|
202
|
+
}
|
|
203
|
+
for (const entry of versions) {
|
|
204
|
+
if (entry.isDirectory() !== true) continue
|
|
205
|
+
// Only JDK trees: devkit also holds maven/node/etc., and a bare `*` would
|
|
206
|
+
// turn into a ProcessName that matches programs we never meant to intercept.
|
|
207
|
+
if (/^jdk/i.test(entry.name) !== true) continue
|
|
208
|
+
if (existingFile(join(devkit, entry.name, 'bin', 'java.exe')) === false) continue
|
|
209
|
+
// The rule names the IMAGE (java.exe), not a pinned file: YonCode upgrades its
|
|
210
|
+
// JDK in place, and other java.exe processes on the box need the same tunnel.
|
|
211
|
+
out.java = 'java.exe'
|
|
212
|
+
out.home = home
|
|
213
|
+
break
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (out.git === '' && existingFile(join(devkit, 'git', 'bin', 'git.exe')) === true) {
|
|
217
|
+
out.git = 'git.exe'
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (out.git === '') {
|
|
221
|
+
for (const candidate of Array.isArray(options.gitPaths) === true ? options.gitPaths : []) {
|
|
222
|
+
if (typeof candidate !== 'string' || candidate === '' || existingFile(candidate) !== true) continue
|
|
223
|
+
// Same idea as java: one git.exe row covers the devkit copy and every other install.
|
|
224
|
+
out.git = 'git.exe'
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Where YonCode runs on this machine, as paths this process can stat. */
|
|
232
|
+
export function yoncodeHomes(cfg) {
|
|
233
|
+
const explicit = typeof cfg?.yoncodeHome === 'string' && cfg.yoncodeHome !== ''
|
|
234
|
+
? [cfg.yoncodeHome]
|
|
235
|
+
: yoncodeCandidates(cfg ?? {})
|
|
236
|
+
return explicit.filter((value) => existingDirectory(value) === true)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The git YonCode uses when it is not the one bundled with the devkit: the Windows
|
|
241
|
+
* installs reachable from WSL, then whatever `git` resolves to here.
|
|
242
|
+
*/
|
|
243
|
+
export function candidateGitPaths(options = {}) {
|
|
244
|
+
if (Array.isArray(options.gitPaths) === true) return options.gitPaths
|
|
245
|
+
const out = [
|
|
246
|
+
'/mnt/c/Program Files/Git/bin/git.exe',
|
|
247
|
+
'/mnt/d/Program Files/Git/bin/git.exe',
|
|
248
|
+
'/mnt/c/Program Files/Git/cmd/git.exe',
|
|
249
|
+
'/mnt/d/Program Files/Git/cmd/git.exe',
|
|
250
|
+
]
|
|
251
|
+
if (process.platform === 'win32') {
|
|
252
|
+
try {
|
|
253
|
+
const where = execFileSync('where.exe', ['git'], { encoding: 'utf8', timeout: 4000, windowsHide: true })
|
|
254
|
+
for (const line of where.split(/\r?\n/)) if (line.trim() !== '') out.unshift(line.trim())
|
|
255
|
+
} catch {
|
|
256
|
+
// Git is optional here: without it the generic `git` rule already covers PATH.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Rules that name YonCode's own executables, labelled so the dialog and the
|
|
264
|
+
* ProxyBridge profile say where they came from.
|
|
265
|
+
*
|
|
266
|
+
* `hosts` is the set of private /24 segments the backend really dials, derived from the
|
|
267
|
+
* resolved endpoints: YonCode's java must be redirected to the same place our own
|
|
268
|
+
* `java` rows point, and hardcoding any range would silently miss another environment.
|
|
269
|
+
*/
|
|
270
|
+
export function yoncodeRules(cfg, options = {}) {
|
|
271
|
+
const withJava = options.java !== false
|
|
272
|
+
const hosts = Array.isArray(options.hosts) ? options.hosts.filter((host) => String(host).trim() !== '') : []
|
|
273
|
+
const found = yoncodeProcesses({
|
|
274
|
+
homes: options.homes ?? yoncodeHomes(cfg),
|
|
275
|
+
gitPaths: options.gitPaths ?? candidateGitPaths(options),
|
|
276
|
+
})
|
|
277
|
+
const gitHost = options.gitHost ?? ''
|
|
278
|
+
const rules = []
|
|
279
|
+
if (withJava && found.java !== '' && hosts.length > 0) {
|
|
280
|
+
rules.push({
|
|
281
|
+
id: 'yds-yoncode-java',
|
|
282
|
+
label: 'YonCode java',
|
|
283
|
+
program: found.java,
|
|
284
|
+
// YonCode's own JDK/git are Windows executables: they belong to the Windows side
|
|
285
|
+
// even when this process runs inside WSL.
|
|
286
|
+
side: 'windows',
|
|
287
|
+
hosts: hosts.join(','),
|
|
288
|
+
ports: '*',
|
|
289
|
+
enabled: true,
|
|
290
|
+
shadow: false,
|
|
291
|
+
source: YDS_RULE_SOURCE,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
if (found.git !== '' && gitHost !== '') {
|
|
295
|
+
rules.push({
|
|
296
|
+
id: 'yds-yoncode-git',
|
|
297
|
+
label: 'YonCode git',
|
|
298
|
+
program: found.git,
|
|
299
|
+
side: 'windows',
|
|
300
|
+
hosts: gitHost,
|
|
301
|
+
ports: '*',
|
|
302
|
+
enabled: true,
|
|
303
|
+
shadow: false,
|
|
304
|
+
source: YDS_RULE_SOURCE,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
return rules
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The fallback loopback listeners, derived from the same endpoint list.
|
|
312
|
+
*
|
|
313
|
+
* With ProxyBridge down a JDBC/Redis client inside the JVM cannot be told to use
|
|
314
|
+
* SOCKS, so the tunnel is exposed on `127.0.0.1:<same port>` and the adapted config
|
|
315
|
+
* points there instead. These are ENGINE INPUTS, not rule rows: the dialog shows
|
|
316
|
+
* intent (which programs are proxied), never this plumbing.
|
|
317
|
+
* @param {Array<{host: string, port: number}>} targets - endpoints from the config adapter.
|
|
318
|
+
* @returns {Array<{host: string, port: number}>} one listener per literal-IPv4 endpoint.
|
|
319
|
+
*/
|
|
320
|
+
export function shadowTargetsFromTargets(targets) {
|
|
321
|
+
const out = []
|
|
322
|
+
const seen = new Set()
|
|
323
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
324
|
+
const host = String(target?.host ?? '')
|
|
325
|
+
const port = Number(target?.port)
|
|
326
|
+
if (!isIpv4Literal(host) || !Number.isInteger(port) || port < 1 || port > 65535) continue
|
|
327
|
+
const key = `${host}:${port}`
|
|
328
|
+
if (seen.has(key)) continue
|
|
329
|
+
seen.add(key)
|
|
330
|
+
out.push({ host, port })
|
|
331
|
+
}
|
|
332
|
+
return out
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Rules this project contributes to the machine-wide table.
|
|
338
|
+
*
|
|
339
|
+
* Two rows, both naming a real process: `java` for the internal segment (the JVM
|
|
340
|
+
* is the client of every JDBC/Redis/ES/license endpoint) and `git` for its remote.
|
|
341
|
+
* Endpoints are deliberately NOT rows: a per-endpoint row would have to invent a
|
|
342
|
+
* program name (the old `jdbc` label matched no process at all) and would repeat
|
|
343
|
+
* what the /24 rule already covers.
|
|
344
|
+
* @param {Array<{host: string, port: number}>} targets - endpoints from the config adapter.
|
|
345
|
+
* @param {string} [workspace] - provenance label, not part of the rule identity.
|
|
346
|
+
* @param {{gitHost?: string}} [options] - host of the git remote, when known.
|
|
347
|
+
* @returns {object[]} rule rows in the engine's schema.
|
|
348
|
+
*/
|
|
349
|
+
// Addresses the backend must reach that are not database/redis endpoints: the
|
|
350
|
+
// bind/callback host (relayed back to this machine) and anything the project
|
|
351
|
+
// settings name explicitly. Same rule as the endpoints: private ranges only.
|
|
352
|
+
export function extraHostsOf(cfg) {
|
|
353
|
+
const hosts = []
|
|
354
|
+
const push = (value) => {
|
|
355
|
+
const text = String(value ?? '').trim()
|
|
356
|
+
if (text === '') return
|
|
357
|
+
const host = text.includes(':') ? text.slice(0, text.lastIndexOf(':')) : text
|
|
358
|
+
if (hosts.includes(host) !== true) hosts.push(host)
|
|
359
|
+
}
|
|
360
|
+
push(cfg?.serverBindAddress)
|
|
361
|
+
return hosts
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Private /24 segments (as `1.2.3.*` patterns) for rows that name an executable
|
|
366
|
+
* instead of a service: the java process calls the same databases/redis as the app.
|
|
367
|
+
*/
|
|
368
|
+
export function privateSegmentsOf(targets, extraHosts = []) {
|
|
369
|
+
const segments = new Set()
|
|
370
|
+
const add = (host) => {
|
|
371
|
+
const text = String(host ?? '').trim()
|
|
372
|
+
if (isPrivateIpv4(text) !== true) return
|
|
373
|
+
segments.add(`${text.split('.').slice(0, 3).join('.')}.*`)
|
|
374
|
+
}
|
|
375
|
+
for (const target of Array.isArray(targets) ? targets : []) add(target?.host)
|
|
376
|
+
for (const host of Array.isArray(extraHosts) ? extraHosts : []) add(host)
|
|
377
|
+
return [...segments].sort()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// `java` defaults to false on purpose: config.js's `proxy.javaRules` also defaults off
|
|
381
|
+
// (the YMS JVM gets -DsocksProxyHost from `yds yoncode patch`, and WinDivert resets the
|
|
382
|
+
// Oracle TNS handshake). Both entry points must agree, or a direct helper call silently
|
|
383
|
+
// turns java interception back on.
|
|
384
|
+
//
|
|
385
|
+
// Two SIDES, not two workspaces: ProxyBridge matches `java.exe`/`git.exe` on Windows and
|
|
386
|
+
// `java`/`git` on WSL/Linux. Both rule sets are generated in one go, so the panel's
|
|
387
|
+
// global table shows the WSL-side rows that used to be missing (the WSL JVM was never
|
|
388
|
+
// intercepted). On Windows only the `.exe` rows make sense and are generated.
|
|
389
|
+
export function proxyRulesFromTargets(targets, { gitHost = '', java = false, extraHosts = [], platform = process.platform } = {}) {
|
|
390
|
+
const rules = []
|
|
391
|
+
const segments = new Set()
|
|
392
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
393
|
+
const host = String(target?.host ?? '')
|
|
394
|
+
const port = Number(target?.port)
|
|
395
|
+
if (host === '' || !Number.isInteger(port) || port <= 0 || port > 65535) continue
|
|
396
|
+
if (isPrivateIpv4(host)) segments.add(host.split('.').slice(0, 3).join('.'))
|
|
397
|
+
}
|
|
398
|
+
// Hosts the app itself has to dial that the bootstrap never lists: the callback /
|
|
399
|
+
// bind address (the reverse-proxied machine that relays gateway traffic back here)
|
|
400
|
+
// is such a case — Java cannot reach it without the tunnel either.
|
|
401
|
+
for (const host of Array.isArray(extraHosts) ? extraHosts : []) {
|
|
402
|
+
const value = String(host ?? '').trim()
|
|
403
|
+
if (isPrivateIpv4(value) !== true) continue
|
|
404
|
+
segments.add(value.split('.').slice(0, 3).join('.'))
|
|
405
|
+
}
|
|
406
|
+
// ONE java rule per side for every internal segment, hosts comma-separated: the
|
|
407
|
+
// backend also talks to hosts the bootstrap never lists (license, ES, kafka), so a
|
|
408
|
+
// port list would go stale — and one row per /24 only made the table longer.
|
|
409
|
+
const javaHosts = java ? Array.from(segments).sort().map((segment) => `${segment}.*`) : []
|
|
410
|
+
const sides = platform === 'win32'
|
|
411
|
+
? [{ side: 'windows', java: 'java.exe', git: 'git.exe' }]
|
|
412
|
+
: [
|
|
413
|
+
{ side: 'windows', java: 'java.exe', git: 'git.exe' },
|
|
414
|
+
{ side: 'local', java: 'java', git: 'git' },
|
|
415
|
+
]
|
|
416
|
+
for (const entry of sides) {
|
|
417
|
+
const local = entry.side === 'local'
|
|
418
|
+
if (javaHosts.length > 0) {
|
|
419
|
+
rules.push({
|
|
420
|
+
id: local ? 'yds-java-local' : 'yds-java',
|
|
421
|
+
// The IMAGE name, not a file path: one row covers every java.exe on the box
|
|
422
|
+
// (YonCode's bundled JDK, the IDE's own, a terminal's), and a JDK upgrade
|
|
423
|
+
// never invalidates it. The local side matches the WSL/Linux process name.
|
|
424
|
+
program: entry.java,
|
|
425
|
+
side: entry.side,
|
|
426
|
+
hosts: javaHosts.join(','),
|
|
427
|
+
ports: '*',
|
|
428
|
+
enabled: true,
|
|
429
|
+
shadow: false,
|
|
430
|
+
source: YDS_RULE_SOURCE,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
// git reaches its remote through the same tunnel; `git config http.<host>.proxy`
|
|
434
|
+
// stays as the belt for terminals that do not go through ProxyBridge.
|
|
435
|
+
if (gitHost !== '') {
|
|
436
|
+
rules.push({
|
|
437
|
+
id: local ? 'yds-git-local' : 'yds-git',
|
|
438
|
+
program: entry.git,
|
|
439
|
+
side: entry.side,
|
|
440
|
+
hosts: gitHost,
|
|
441
|
+
ports: '*',
|
|
442
|
+
enabled: true,
|
|
443
|
+
shadow: false,
|
|
444
|
+
source: YDS_RULE_SOURCE,
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return rules
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The git host from the configured clone URL, or `''` when it has none. */
|
|
452
|
+
export function gitHostOf(cfg) {
|
|
453
|
+
const url = typeof cfg?.git?.url === 'string' ? cfg.git.url : ''
|
|
454
|
+
if (url === '') return ''
|
|
455
|
+
try { return new URL(url).host } catch { return '' }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Merge the YDS-owned rules into the IDE's proxy config and write it back.
|
|
460
|
+
* @returns {{config: object, rules: object[]}} the merged config.
|
|
461
|
+
*/
|
|
462
|
+
/** The rules this project owns, in one deterministic order (generic rows first). */
|
|
463
|
+
export function ydsRulesFor(cfg, targets, { java = cfg?.proxyJavaRules === true, platform = process.platform, log } = {}) {
|
|
464
|
+
return mergeYdsRules([], [
|
|
465
|
+
...proxyRulesFromTargets(targets, { gitHost: gitHostOf(cfg), java, extraHosts: extraHostsOf(cfg), platform }),
|
|
466
|
+
// YonCode runs its own JDK/git from outside the project. These rows are kept as a
|
|
467
|
+
// fallback for invocations where the generic rows above are absent; when they are
|
|
468
|
+
// present the side+program key is already taken and the duplicate is merged.
|
|
469
|
+
...yoncodeRules(cfg, {
|
|
470
|
+
gitHost: gitHostOf(cfg),
|
|
471
|
+
java,
|
|
472
|
+
hosts: privateSegmentsOf(targets, extraHostsOf(cfg)),
|
|
473
|
+
}),
|
|
474
|
+
], { log })
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function syncProxyConfig(cfg, targets, { log, mapping = true, java = cfg?.proxyJavaRules === true, platform = process.platform } = {}) {
|
|
478
|
+
const loaded = cfg.__ideProxy
|
|
479
|
+
if (loaded === undefined || loaded === null || loaded.available !== true) throw new Error('代理引擎未加载')
|
|
480
|
+
const mod = loaded.mod
|
|
481
|
+
// One read, one write: reading the shared table twice around the merge opened a
|
|
482
|
+
// lost-update window between them (review D-P2).
|
|
483
|
+
const stored = mod.readProxyConfig(cfg.workspace)
|
|
484
|
+
// The table is machine-wide AND global, and the daemon saves it wholesale
|
|
485
|
+
// (`POST /v1/proxy` is a whole-table PUT). So the complete table is built here:
|
|
486
|
+
// every non-yds row kept, every yds row replaced by this run's set.
|
|
487
|
+
const fresh = ydsRulesFor(cfg, targets, { java, platform, log })
|
|
488
|
+
const rules = mergeYdsRules(stored?.rules, fresh, { log })
|
|
489
|
+
// Bound only while the system layer is unavailable; in interception mode this is
|
|
490
|
+
// empty, so nothing is bound on loopback at all.
|
|
491
|
+
const shadows = mapping === false ? [] : shadowTargetsFromTargets(targets)
|
|
492
|
+
const upstream = cfg.socks ?? null
|
|
493
|
+
// The borrowed tier goes through `mergeConfigs` with `replaceAllRules`: the IDE
|
|
494
|
+
// keeps owning socks/listenPort/shadows, while the rules are replaced wholesale by
|
|
495
|
+
// the complete table above. `replaceRulesFrom` (the old per-source patch) is retired.
|
|
496
|
+
const merged = mod.mergeConfigs(stored, {
|
|
497
|
+
socks: upstream === null ? { host: '', port: 0 } : { host: upstream.host, port: upstream.port },
|
|
498
|
+
listenPort: cfg.proxyListenPort ?? stored?.listenPort,
|
|
499
|
+
shadows,
|
|
500
|
+
replaceAllRules: true,
|
|
501
|
+
rules,
|
|
502
|
+
})
|
|
503
|
+
// Pin the table we computed even if an older dsh-ris-ide build ignores the flag: a
|
|
504
|
+
// stale yds row left behind here would resurface as an intercepted process.
|
|
505
|
+
const saved = mod.writeProxyConfig(cfg.workspace, { ...merged, rules })
|
|
506
|
+
const config = saved
|
|
507
|
+
log?.(`代理规则已同步:${fresh.length} 条 yds 规则(共享表共 ${rules.length} 条)`)
|
|
508
|
+
return { config: saved, rules, withdrawn: withdrawnBetween(stored?.rules, rules) }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Run one `git config --global` mutation; a missing git is a reported no-op. */
|
|
512
|
+
function runGitConfig(args) {
|
|
513
|
+
return new Promise((resolvePromise) => {
|
|
514
|
+
const child = spawn('git', ['config', '--global', ...args], { windowsHide: true })
|
|
515
|
+
let stderr = ''
|
|
516
|
+
let settled = false
|
|
517
|
+
const done = (result) => { if (!settled) { settled = true; resolvePromise(result) } }
|
|
518
|
+
child.stderr?.on('data', (chunk) => { if (stderr.length < 4096) stderr += chunk.toString('utf8') })
|
|
519
|
+
child.on('error', (error) => done({ ok: false, error: String(error?.message ?? error) }))
|
|
520
|
+
child.on('close', (code) => done(code === 0 ? { ok: true } : { ok: false, error: stderr.trim() || `git exited with ${code}` }))
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Write (or clear) the scoped git proxy entry, so EVERY git invocation honours
|
|
526
|
+
* SOCKS — including dsh-git's own commands, which never get `-c http.proxy`.
|
|
527
|
+
*
|
|
528
|
+
* The key is scoped to the configured clone host (`http.https://<host>/.proxy`)
|
|
529
|
+
* rather than the global `http.proxy`: a user's other remotes must not silently
|
|
530
|
+
* start going through this tunnel.
|
|
531
|
+
* @returns {Promise<{ok: boolean, key: string, value?: string, error?: string}>}
|
|
532
|
+
*/
|
|
533
|
+
/**
|
|
534
|
+
* Persist (or forget) "this git proxy entry is ours" for the exit hook.
|
|
535
|
+
*
|
|
536
|
+
* dsh-ris-ide owns the file (`~/.dsh-ris-ide/git-proxy.json`) and its unset is
|
|
537
|
+
* keyed on exactly this record; writing it from the CLI path means a `yds stop`
|
|
538
|
+
* — or simply closing dsh — undoes the entry no matter which side wrote it.
|
|
539
|
+
* @param {object} mod - the loaded dsh-ris-ide proxy module.
|
|
540
|
+
* @param {{key: string, host: string}|null} state - null clears the record.
|
|
541
|
+
*/
|
|
542
|
+
function recordGitProxyState(mod, state) {
|
|
543
|
+
try {
|
|
544
|
+
if (typeof mod?.proxyHomeDir !== 'function') return
|
|
545
|
+
const dir = mod.proxyHomeDir()
|
|
546
|
+
const path = join(dir, 'git-proxy.json')
|
|
547
|
+
if (state === null) {
|
|
548
|
+
rmSync(path, { force: true })
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
mkdirSync(dir, { recursive: true })
|
|
552
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
|
|
553
|
+
} catch {
|
|
554
|
+
// A missing state file only costs the exit-hook undo; never fail the command.
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function applyGitProxy(cfg, { enabled = true } = {}) {
|
|
559
|
+
const loaded = cfg.__ideProxy
|
|
560
|
+
if (loaded === undefined || loaded === null || loaded.available !== true) return { ok: false, key: '', error: '代理引擎未加载' }
|
|
561
|
+
const host = gitHostOf(cfg)
|
|
562
|
+
if (host === '' && enabled) return { ok: false, key: '', error: '未配置 git.url,无法确定要写哪一条 git 代理' }
|
|
563
|
+
const plan = loaded.mod.gitProxyPlan(loaded.mod.readProxyConfig(cfg.workspace), { host, enabled })
|
|
564
|
+
const result = enabled
|
|
565
|
+
? await runGitConfig([plan.key, plan.value])
|
|
566
|
+
: await runGitConfig(['--unset', plan.key])
|
|
567
|
+
// The Windows-side git has its own config file and cannot resolve the internal
|
|
568
|
+
// host, so the same scoped entry has to be written there too — YonCode runs git on
|
|
569
|
+
// that side of the boundary.
|
|
570
|
+
const windows = typeof loaded.mod.runWindowsGitConfig === 'function'
|
|
571
|
+
? loaded.mod.runWindowsGitConfig(enabled ? [plan.key, plan.value] : ['--unset', plan.key])
|
|
572
|
+
: { ok: false, skipped: true, error: '引擎版本过旧,不支持 Windows 侧 git' }
|
|
573
|
+
// Record it where dsh-ris-ide keeps the same fact: its exit hook then clears a
|
|
574
|
+
// tunnel we wrote from the CLI, so a dead tunnel never leaves git pointed at it.
|
|
575
|
+
recordGitProxyState(loaded.mod, enabled ? { key: plan.key, host, windows: windows.ok === true } : null)
|
|
576
|
+
return Object.assign({ key: plan.key, value: enabled ? plan.value : '', windows }, result)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Make sure the IDE engine runs the current YDS rules.
|
|
581
|
+
*
|
|
582
|
+
* A changed rule set needs a restart: the engine binds its shadows at startup and
|
|
583
|
+
* never re-reads the file (that is also why the dialog says "restart to apply").
|
|
584
|
+
* @returns {Promise<{mode: string, ok?: boolean, error?: string, path?: string,
|
|
585
|
+
* rules?: object[], shadowPorts?: number[], restarted?: boolean}>}
|
|
586
|
+
*/
|
|
587
|
+
/**
|
|
588
|
+
* Start the system-level interception that pairs with the tunnel.
|
|
589
|
+
*
|
|
590
|
+
* The engine only covers programs we spawn, so the redirector is what makes an
|
|
591
|
+
* IDE-launched java or a terminal git go through the tunnel at all. Elevation is
|
|
592
|
+
* the normal case: a Windows side triggers UAC, a Linux side needs passwordless
|
|
593
|
+
* sudo — a side that cannot be elevated is reported, never fatal.
|
|
594
|
+
* @param {object} cfg - resolved plugin config.
|
|
595
|
+
* @param {{log?: Function}} [options] - logger.
|
|
596
|
+
* @returns {Promise<{available: boolean, started: object[], skipped: object[], running: boolean}>} outcome.
|
|
597
|
+
*/
|
|
598
|
+
export async function startBorrowedRedirector(cfg, { log, sides } = {}) {
|
|
599
|
+
const loaded = await loadIdeRedirector(cfg)
|
|
600
|
+
if (loaded.available === false) return { available: false, started: [], skipped: [], running: false }
|
|
601
|
+
if (typeof loaded.mod.startRedirectors !== 'function') return { available: false, started: [], skipped: [], running: false }
|
|
602
|
+
// `reload: true` makes an already-running bridge restart with the profile we just
|
|
603
|
+
// wrote — edited yds rules must apply without a manual stop/start (or a new UAC).
|
|
604
|
+
const result = await loaded.mod.startRedirectors(Array.isArray(sides) ? { sides, reload: true } : { reload: true })
|
|
605
|
+
const started = Array.isArray(result?.started) ? result.started : []
|
|
606
|
+
const skipped = Array.isArray(result?.skipped) ? result.skipped : []
|
|
607
|
+
if (started.length > 0) {
|
|
608
|
+
const reloaded = started.filter((item) => item.reloaded === true)
|
|
609
|
+
const fresh = started.filter((item) => item.reloaded !== true)
|
|
610
|
+
const parts = []
|
|
611
|
+
if (fresh.length > 0) parts.push(`已启动(${fresh.map((item) => `${item.side} pid ${item.pid ?? '?'}`).join(',')})`)
|
|
612
|
+
if (reloaded.length > 0) parts.push(`已按新规则重载(${reloaded.map((item) => item.side).join(',')})`)
|
|
613
|
+
log?.(`系统层拦截${parts.join(';')}`)
|
|
614
|
+
}
|
|
615
|
+
else if (skipped.length > 0 && typeof skipped[0]?.reason === 'string') log?.(`系统层拦截未启动:${skipped[0].reason}`)
|
|
616
|
+
return { available: true, started, skipped, running: result?.running === true }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Stop the redirector and clear the git proxy that were started with the tunnel.
|
|
621
|
+
*
|
|
622
|
+
* Both are machine-scoped side effects of `yds start`, so `yds stop` removes them
|
|
623
|
+
* even if this run did not create them — a leftover redirector keeps routing
|
|
624
|
+
* traffic with no UI, and a leftover git proxy breaks `git` once the tunnel dies.
|
|
625
|
+
* @param {object} cfg - resolved plugin config.
|
|
626
|
+
* @param {{log?: Function, keepGit?: boolean}} [options] - logger / skip git undo.
|
|
627
|
+
* @returns {Promise<{redirector: object, git: object}>} per-layer outcome.
|
|
628
|
+
*/
|
|
629
|
+
export async function stopBorrowedSideEffects(cfg, { log, keepGit = false, daemonReload = null } = {}) {
|
|
630
|
+
let redirector = { stopped: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
|
|
631
|
+
if (daemonReload !== null) {
|
|
632
|
+
// risd tier: POST /v1/proxy already reloaded (or reported why it could not
|
|
633
|
+
// reload) the machine bridge. Reloading it here too would be a second writer
|
|
634
|
+
// that is not serialised with the daemon (review D-P2-10), so render its note.
|
|
635
|
+
redirector = {
|
|
636
|
+
stopped: false,
|
|
637
|
+
viaDaemon: true,
|
|
638
|
+
reason: daemonReload === ''
|
|
639
|
+
? '由 risd daemon 管理(规则已保存;daemon 未要求重启桥接)'
|
|
640
|
+
: `由 risd daemon 管理:${daemonReload}`,
|
|
641
|
+
}
|
|
642
|
+
log?.(`系统层拦截:${redirector.reason}`)
|
|
643
|
+
} else {
|
|
644
|
+
const loaded = await loadIdeRedirector(cfg)
|
|
645
|
+
// The bridge is machine-wide: an IDE window may still need it. Reload it with the
|
|
646
|
+
// profile that no longer contains our rules instead of stopping it; stopping is
|
|
647
|
+
// what used to kill the IDE's interception on `yds stop`.
|
|
648
|
+
if (loaded.available === true && typeof loaded.mod.reloadRedirectorsWithProfile === 'function') {
|
|
649
|
+
const status = typeof loaded.mod.redirectorStatus === 'function' ? loaded.mod.redirectorStatus() : { running: false }
|
|
650
|
+
if (status.running !== true) redirector = { stopped: false, reason: '未在运行' }
|
|
651
|
+
else {
|
|
652
|
+
const reload = await loaded.mod.reloadRedirectorsWithProfile(cfg.workspace, { sides: ['windows', 'local'] })
|
|
653
|
+
const reloaded = Array.isArray(reload.reloaded) ? reload.reloaded.length : 0
|
|
654
|
+
redirector = reloaded > 0
|
|
655
|
+
? { stopped: false, reloaded: true, reason: '已按剩余规则重载(引擎与拦截保留)' }
|
|
656
|
+
: { stopped: false, reason: '未能重载,系统层拦截仍按旧规则运行(需手动重启桥接)' }
|
|
657
|
+
log?.(`系统层拦截:${redirector.reason}`)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
let git = { ok: true, key: '' }
|
|
662
|
+
if (keepGit !== true) git = await applyGitProxy(cfg, { enabled: false })
|
|
663
|
+
return { redirector, git }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Write this project's rules into the shared table WITHOUT starting an engine.
|
|
668
|
+
*
|
|
669
|
+
* Interception mode needs the rules (the ProxyBridge profile is built from them)
|
|
670
|
+
* but must not bind anything: the redirector already carries the connections.
|
|
671
|
+
* @param {object} cfg - resolved plugin config.
|
|
672
|
+
* @param {object[]} targets - endpoints collected from the config.
|
|
673
|
+
* @param {{log?: Function}} [options] - logger.
|
|
674
|
+
* @returns {Promise<{mode: string, rules?: object[]}>} sync outcome.
|
|
675
|
+
*/
|
|
676
|
+
export async function syncBorrowedRules(cfg, targets, { log } = {}) {
|
|
677
|
+
// Preferred tier: a running risd owns the table, the engine and the bridge.
|
|
678
|
+
const risd = await probeRisd()
|
|
679
|
+
if (risd.usable === true) {
|
|
680
|
+
const rules = ydsRulesFor(cfg, targets)
|
|
681
|
+
// No `shadows` argument on purpose: the daemon's write contract only takes
|
|
682
|
+
// socks/listenPort/rules (incoming.shadows is dropped), so passing `[]` here
|
|
683
|
+
// would look like "clear the mappings" while doing nothing (review D-P2-3).
|
|
684
|
+
// 127.0.0.1 mappings of the risd engine are owned by the daemon / IDE side.
|
|
685
|
+
const saved = await putRules(cfg.workspace ?? '', rules, { gitHost: gitHostOf(cfg), log })
|
|
686
|
+
if (saved.ok !== true) throw new Error(`代理规则写入 risd 失败:${saved.error}`)
|
|
687
|
+
log?.(`代理规则已写入 risd(${rules.length} 条,source=${YDS_RULE_SOURCE})`)
|
|
688
|
+
return { mode: 'risd', rules, reloadNote: saved.reloadNote ?? '' }
|
|
689
|
+
}
|
|
690
|
+
const loaded = await loadIdeProxy(cfg)
|
|
691
|
+
cfg.__ideProxy = loaded
|
|
692
|
+
if (!loaded.available) return { mode: 'unavailable' }
|
|
693
|
+
const { rules } = syncProxyConfig(cfg, targets, { log, mapping: false })
|
|
694
|
+
log?.('代理规则已写入(系统层拦截模式,无 127.0.0.1 映射)')
|
|
695
|
+
// A running bridge parsed its profile once at start, so rewriting the table is not
|
|
696
|
+
// enough: restart it with the new rules — or, if that fails, stop it. Leaving the
|
|
697
|
+
// old profile running is exactly the "old proxy cannot be cancelled" failure.
|
|
698
|
+
const redirector = await loadIdeRedirector(cfg)
|
|
699
|
+
if (redirector.available === true && typeof redirector.mod.reloadRedirectorsWithProfile === 'function') {
|
|
700
|
+
const status = typeof redirector.mod.redirectorStatus === 'function' ? redirector.mod.redirectorStatus() : { running: false }
|
|
701
|
+
if (status.running === true) {
|
|
702
|
+
const reload = await redirector.mod.reloadRedirectorsWithProfile(cfg.workspace, { sides: ['windows', 'local'] })
|
|
703
|
+
const reloaded = Array.isArray(reload.reloaded) ? reload.reloaded.length : 0
|
|
704
|
+
if (reloaded > 0) {
|
|
705
|
+
log?.('系统层拦截已按新规则重启')
|
|
706
|
+
return { mode: 'ide', rules, reloadNote: '系统层拦截已按新规则自动重启' }
|
|
707
|
+
}
|
|
708
|
+
const stopped = typeof redirector.mod.stopRedirector === 'function'
|
|
709
|
+
? await redirector.mod.stopRedirector()
|
|
710
|
+
: { stopped: false }
|
|
711
|
+
const note = stopped.stopped === true
|
|
712
|
+
? '系统层拦截未能重载,已停止(避免继续按旧规则代理)'
|
|
713
|
+
: '系统层拦截未能重载,旧规则可能仍在生效(请重启桥接)'
|
|
714
|
+
log?.(note)
|
|
715
|
+
return { mode: 'ide', rules, reloadNote: note }
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return { mode: 'ide', rules }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export async function ensureBorrowedProxy(cfg, targets, { log, mapping = true } = {}) {
|
|
722
|
+
// Direct mode (no `.env` SOCKS settings) has nothing to forward through: the
|
|
723
|
+
// config's own addresses are reachable, so no engine is started at all.
|
|
724
|
+
if (cfg.socks === null || cfg.socks === undefined) return { mode: 'none' }
|
|
725
|
+
if (Array.isArray(targets) && targets.length === 0) {
|
|
726
|
+
log?.('代理引擎:bootstrap 没有需要转发的内网端点,跳过')
|
|
727
|
+
return { mode: 'none' }
|
|
728
|
+
}
|
|
729
|
+
if (cfg.proxyEngine === 'self') { log?.('代理引擎:按 .env 配置使用内置转发器(proxy.engine=self)'); return { mode: 'unavailable', error: 'disabled by proxy.engine=self' } }
|
|
730
|
+
// Preferred tier: the daemon owns the engine, so yds writes rules + a lease and
|
|
731
|
+
// never starts a second engine (two owners would fight for the same shadows).
|
|
732
|
+
const risd = await probeRisd()
|
|
733
|
+
if (risd.usable === true) {
|
|
734
|
+
const rules = ydsRulesFor(cfg, targets)
|
|
735
|
+
const saved = await putRules(cfg.workspace ?? '', rules, { gitHost: gitHostOf(cfg), log })
|
|
736
|
+
if (saved.ok !== true) return { mode: 'risd', ok: false, error: `代理规则写入 risd 失败:${saved.error}` }
|
|
737
|
+
const started = await startEngine(cfg.workspace ?? '')
|
|
738
|
+
if (started.ok !== true) return { mode: 'risd', ok: false, error: `risd 启动引擎失败:${started.error}` }
|
|
739
|
+
const status = await engineStatus(cfg.workspace ?? '')
|
|
740
|
+
const pid = status.engine?.pid ?? started.result?.pid ?? null
|
|
741
|
+
const ports = Array.isArray(status.engine?.listen) ? status.engine.listen.map((item) => item.port).filter((port) => Number.isInteger(port)) : []
|
|
742
|
+
log?.(`代理引擎:risd daemon 运行中(pid ${pid ?? '?'})${ports.length > 0 ? `,监听 ${ports.join(', ')}` : ''}`)
|
|
743
|
+
return { mode: 'risd', ok: true, pid, rules, shadowPorts: ports, listenPorts: ports }
|
|
744
|
+
}
|
|
745
|
+
const loaded = await loadIdeProxy(cfg)
|
|
746
|
+
cfg.__ideProxy = loaded
|
|
747
|
+
if (!loaded.available) {
|
|
748
|
+
// Standalone by design: the built-in forwarder is a complete fallback, so
|
|
749
|
+
// the sibling plugin is only ever a recommendation (one shared engine).
|
|
750
|
+
log?.(`代理引擎:未找到 dsh-ris-ide(${loaded.error}),回退内置转发器`
|
|
751
|
+
+ '(可选:装上 dsh-ris-ide 可在同一个 dsh 里获得 IDE 面板与服务调试能力)')
|
|
752
|
+
return { mode: 'unavailable', error: loaded.error }
|
|
753
|
+
}
|
|
754
|
+
const mod = loaded.mod
|
|
755
|
+
const before = mod.readProxyConfig(cfg.workspace)
|
|
756
|
+
const { config, rules } = syncProxyConfig(cfg, targets, { log, mapping })
|
|
757
|
+
const running = mod.proxyPid(cfg.workspace) !== null
|
|
758
|
+
// The engine watches `proxy.json` and reloads it (dsh-ris-ide/lib/proxy.js:
|
|
759
|
+
// watchFile -> reload -> applyShadows), so an edited table reaches a running engine
|
|
760
|
+
// within about a second. The restart below is belt-and-braces: it guarantees the
|
|
761
|
+
// new shadow listeners are bound BEFORE the process we are about to launch dials
|
|
762
|
+
// 127.0.0.1; it is not needed because "a running engine holds the old rules" — that
|
|
763
|
+
// model is obsolete (review D-P2-9).
|
|
764
|
+
const changed = JSON.stringify(before.rules) !== JSON.stringify(config.rules)
|
|
765
|
+
let restarted = false
|
|
766
|
+
if (running && changed) {
|
|
767
|
+
log?.('代理规则变化,重启代理引擎…')
|
|
768
|
+
await mod.stopProxy(cfg.workspace)
|
|
769
|
+
restarted = true
|
|
770
|
+
}
|
|
771
|
+
const started = await mod.startProxy(cfg.workspace)
|
|
772
|
+
if (started.ok !== true) return { mode: 'ide', ok: false, error: started.error ?? '代理引擎启动失败', path: loaded.path, rules }
|
|
773
|
+
// The loopback listeners are a config field now, not rule rows: read them back
|
|
774
|
+
// from the file the engine just loaded instead of guessing from the rules.
|
|
775
|
+
const shadowPorts = (mod.readProxyConfig(cfg.workspace)?.shadows ?? []).map((entry) => entry.localPort)
|
|
776
|
+
const shadowNote = shadowPorts.length > 0
|
|
777
|
+
? `,127.0.0.1 兜底映射 ${shadowPorts.length} 个端口(ProxyBridge 未运行时的后备)`
|
|
778
|
+
: ',无 127.0.0.1 兜底映射(系统层拦截生效)'
|
|
779
|
+
log?.(`代理引擎:dsh-ris-ide 运行中(pid ${started.pid ?? '?'})${shadowNote}${restarted ? '(已重启)' : ''}`)
|
|
780
|
+
return { mode: 'ide', ok: true, path: loaded.path, pid: started.pid ?? null, rules, shadowPorts, restarted }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Take a daemon lease owned by a long-lived pid (the YMS process, or the
|
|
785
|
+
* foreground CLI) so the engine outlives this short `yds` process.
|
|
786
|
+
*/
|
|
787
|
+
export async function holdBorrowedLease(cfg, { ownerPid = process.pid, log } = {}) {
|
|
788
|
+
const risd = await probeRisd()
|
|
789
|
+
if (risd.usable !== true) return { ok: false, reason: risd.reason }
|
|
790
|
+
// A previous YMS that was killed with -9 leaves a record whose pid is gone; the
|
|
791
|
+
// daemon only reaps by pid, so drop our stale record before taking a fresh one
|
|
792
|
+
// (review D-P2-6).
|
|
793
|
+
if (dropDeadLease(cfg.workspace ?? '') === true) log?.('已清理该 workspace 的死租约记录')
|
|
794
|
+
const lease = await acquireLease({ cwd: cfg.workspace ?? '', state: risd.state, ownerPid })
|
|
795
|
+
if (lease.ok !== true) {
|
|
796
|
+
log?.(`risd 租约未登记:${lease.error}`)
|
|
797
|
+
return { ok: false, error: lease.error }
|
|
798
|
+
}
|
|
799
|
+
rememberLease(cfg.workspace ?? '', { id: lease.id, ownerPid, port: risd.state.port })
|
|
800
|
+
log?.(`risd 租约已登记(owner pid ${ownerPid})`)
|
|
801
|
+
return { ok: true, id: lease.id }
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Drop one specific daemon lease (the owner pid changed, e.g. CLI → YMS). */
|
|
805
|
+
export async function dropBorrowedLease(id, { log } = {}) {
|
|
806
|
+
if (typeof id !== 'string' || id === '') return { ok: true, released: false }
|
|
807
|
+
const risd = await probeRisd()
|
|
808
|
+
if (risd.usable !== true) return { ok: false, reason: risd.reason }
|
|
809
|
+
const released = await releaseLease(id, { state: risd.state })
|
|
810
|
+
if (released.ok === true) log?.('risd 租约已移交(旧 owner 释放)')
|
|
811
|
+
return released
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Release the lease recorded for this workspace; idempotent when none exists. */
|
|
815
|
+
export async function releaseBorrowedLease(cfg, { log } = {}) {
|
|
816
|
+
const entry = readLease(cfg.workspace ?? '')
|
|
817
|
+
if (entry === null) return { ok: true, released: false }
|
|
818
|
+
const risd = await probeRisd()
|
|
819
|
+
if (risd.usable !== true) {
|
|
820
|
+
// No daemon: nothing holds the lease any more, so the local record is stale.
|
|
821
|
+
forgetLease(cfg.workspace ?? '')
|
|
822
|
+
return { ok: true, released: false, reason: 'risd 未运行' }
|
|
823
|
+
}
|
|
824
|
+
const released = await releaseLease(entry.id, { state: risd.state })
|
|
825
|
+
if (released.ok !== true) {
|
|
826
|
+
// Keep the record so a retry (`yds stop` again) can release it instead of
|
|
827
|
+
// waiting for the daemon's 3 s sweep to notice the dead owner (review D-P2-6).
|
|
828
|
+
return { ok: false, released: false, reason: released.error || '释放租约失败' }
|
|
829
|
+
}
|
|
830
|
+
forgetLease(cfg.workspace ?? '')
|
|
831
|
+
log?.(`risd 租约已释放(${entry.id})`)
|
|
832
|
+
return { ok: true, released: true }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Withdraw this plugin's share of the tunnel — and leave the ENGINE running.
|
|
837
|
+
*
|
|
838
|
+
* The engine is machine-wide: an IDE window (or another dsh) may still be using it.
|
|
839
|
+
* So `yds stop` releases the risd lease, removes every yds rule from the global table
|
|
840
|
+
* and lets the machine-level layers settle; killing the engine here is what used to
|
|
841
|
+
* interrupt the IDE's tunnel (the old "yds stop kills the shared engine" defect).
|
|
842
|
+
*/
|
|
843
|
+
export async function stopBorrowedProxy(cfg, { log } = {}) {
|
|
844
|
+
const workspace = cfg.workspace ?? ''
|
|
845
|
+
const risd = await probeRisd()
|
|
846
|
+
if (risd.usable === true) {
|
|
847
|
+
await releaseBorrowedLease(cfg)
|
|
848
|
+
// `[]` removes EVERY yds row: the table is global and yds owns exactly the rows
|
|
849
|
+
// it injected, so a stop must not leave its rows behind — and there is no
|
|
850
|
+
// per-workspace share to preserve any more (global-table contract).
|
|
851
|
+
// The daemon reloads the bridge inside POST /v1/proxy; its note is what
|
|
852
|
+
// `yds stop` renders instead of reloading the bridge locally a second time
|
|
853
|
+
// (review D-P2-10).
|
|
854
|
+
const saved = await putRules(workspace, [], { gitHost: '', log })
|
|
855
|
+
return {
|
|
856
|
+
ok: saved.ok === true,
|
|
857
|
+
stopped: false,
|
|
858
|
+
engine: 'kept',
|
|
859
|
+
mode: 'risd',
|
|
860
|
+
withdrawn: saved.withdrawn ?? { rules: 0, hosts: 0 },
|
|
861
|
+
reloadNote: saved.reloadNote ?? '',
|
|
862
|
+
error: saved.ok === true ? '' : saved.error,
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
|
|
866
|
+
if (loaded.available !== true) return { ok: false, error: loaded.error }
|
|
867
|
+
// Borrowed-engine tier: remove every yds row and keep the engine (and the user's
|
|
868
|
+
// own rows) for the IDE. Same blast radius as the risd tier (review D-P1-3/P1-8).
|
|
869
|
+
const mod = loaded.mod
|
|
870
|
+
const stored = mod.readProxyConfig(workspace)
|
|
871
|
+
const rules = mergeYdsRules(stored?.rules, [], { log })
|
|
872
|
+
const merged = typeof mod.mergeConfigs === 'function'
|
|
873
|
+
? mod.mergeConfigs(stored, { replaceAllRules: true, rules })
|
|
874
|
+
: stored
|
|
875
|
+
mod.writeProxyConfig(workspace, { ...(merged !== null && typeof merged === 'object' ? merged : {}), rules })
|
|
876
|
+
return { ok: true, stopped: false, engine: 'kept', mode: 'ide', withdrawn: withdrawnBetween(stored?.rules, rules) }
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Engine status for `yds status` / `yds proxy status`, whichever tier owns it. */
|
|
880
|
+
export async function borrowedProxyStatus(cfg) {
|
|
881
|
+
const risd = await probeRisd()
|
|
882
|
+
if (risd.usable === true) {
|
|
883
|
+
const view = await getView(cfg.workspace ?? '', { state: risd.state })
|
|
884
|
+
const engine = await engineStatus(cfg.workspace ?? '', { state: risd.state })
|
|
885
|
+
const payload = view.view ?? {}
|
|
886
|
+
const info = engine.engine ?? {}
|
|
887
|
+
const listen = Array.isArray(info.listen) ? info.listen : []
|
|
888
|
+
const listenPorts = listen.map((item) => item.port).filter((port) => Number.isInteger(port))
|
|
889
|
+
return {
|
|
890
|
+
available: true,
|
|
891
|
+
mode: 'risd',
|
|
892
|
+
path: `risd:${risd.state.port}`,
|
|
893
|
+
// /v1/engine is the authoritative process view (running/pid/listen); the rule
|
|
894
|
+
// table's numbers describe what was SAVED and can lag the engine (review D-P2-5).
|
|
895
|
+
running: info.running === true || (info.running === undefined && payload.running === true),
|
|
896
|
+
pid: info.pid ?? payload.pid ?? null,
|
|
897
|
+
listenPort: payload.listenPort ?? listenPorts[0] ?? null,
|
|
898
|
+
listenPorts,
|
|
899
|
+
rules: Array.isArray(payload.rules) ? payload.rules : [],
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
|
|
903
|
+
if (loaded.available !== true) return { available: false, error: loaded.error }
|
|
904
|
+
const status = await loaded.mod.proxyStatus(cfg.workspace)
|
|
905
|
+
return { available: true, mode: 'ide', path: loaded.path, running: status.running === true, pid: status.pid ?? null, listenPort: status.config?.listenPort ?? null, rules: status.config?.rules ?? [] }
|
|
906
|
+
}
|