@twinsearth/dsh-2xray 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/bin/README.md +27 -0
  5. package/cordis.patch.yml +37 -0
  6. package/docs/AC-0.md +60 -0
  7. package/docs/EVIDENCE.md +135 -0
  8. package/examples/config.downgrade.json +20 -0
  9. package/examples/config.example.json +22 -0
  10. package/examples/config.multi.downgrade.json +39 -0
  11. package/examples/config.multi.json +46 -0
  12. package/icon.svg +9 -0
  13. package/index.js +89 -0
  14. package/lib/binary.js +133 -0
  15. package/lib/config.js +401 -0
  16. package/lib/control/client.js +100 -0
  17. package/lib/control/grpc.js +121 -0
  18. package/lib/control/protobuf.js +143 -0
  19. package/lib/health.js +109 -0
  20. package/lib/log.js +20 -0
  21. package/lib/paths.js +65 -0
  22. package/lib/process.js +197 -0
  23. package/lib/route.js +299 -0
  24. package/lib/share-link.js +132 -0
  25. package/lib/supervisor.js +687 -0
  26. package/lib/tools.js +106 -0
  27. package/lib/xray-config.js +225 -0
  28. package/locale/en.json +6 -0
  29. package/locale/zh.json +6 -0
  30. package/package.json +42 -4
  31. package/scripts/check-config.mjs +56 -0
  32. package/scripts/parse-share-link.mjs +23 -0
  33. package/scripts/probe-routing.mjs +66 -0
  34. package/scripts/release-check.mjs +104 -0
  35. package/test/binary.test.js +103 -0
  36. package/test/config.test.js +106 -0
  37. package/test/control.test.js +166 -0
  38. package/test/health.test.js +75 -0
  39. package/test/index.test.js +52 -0
  40. package/test/multi-node.test.js +111 -0
  41. package/test/process.test.js +49 -0
  42. package/test/route.test.js +158 -0
  43. package/test/share-link.test.js +113 -0
  44. package/test/supervisor.test.js +525 -0
  45. package/test/tools.test.js +88 -0
  46. package/test/xray-config.test.js +132 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Minimal protobuf wire-format codec for the three Xray control-plane messages
3
+ * this plugin needs. Zero dependencies: shipping @grpc/grpc-js + proto-loader
4
+ * would add a second copy of a large dependency tree to every profile for a
5
+ * handful of string fields.
6
+ *
7
+ * Covered:
8
+ * xray.app.router.command.OverrideBalancerTargetRequest { string balancerTag = 1; string target = 2 }
9
+ * xray.app.router.command.GetBalancerInfoRequest { string tag = 1 }
10
+ * xray.app.router.command.GetBalancerInfoResponse { BalancerMsg balancer = 1 }
11
+ * xray.app.stats.command.QueryStatsRequest { repeated string patterns = 1; bool reset = 2 }
12
+ * xray.app.stats.command.QueryStatsResponse { repeated Stat stat = 1 }
13
+ * xray.app.stats.command.Stat { string name = 1; int64 value = 2 }
14
+ */
15
+
16
+ export function encodeVarint(value) {
17
+ let n = BigInt(value)
18
+ if (n < 0n) throw new RangeError('protobuf varint must be non-negative')
19
+ const bytes = []
20
+ while (n > 0x7fn) {
21
+ bytes.push(Number(n & 0x7fn) | 0x80)
22
+ n >>= 7n
23
+ }
24
+ bytes.push(Number(n))
25
+ return Buffer.from(bytes)
26
+ }
27
+
28
+ function readVarint(buffer, start) {
29
+ let result = 0n
30
+ let shift = 0n
31
+ let position = start
32
+ for (;;) {
33
+ if (position >= buffer.length) throw new Error('protobuf: truncated varint')
34
+ const byte = buffer[position++]
35
+ result |= BigInt(byte & 0x7f) << shift
36
+ if ((byte & 0x80) === 0) break
37
+ shift += 7n
38
+ if (shift > 70n) throw new Error('protobuf: varint too long')
39
+ }
40
+ return [result, position]
41
+ }
42
+
43
+ function fieldKey(field, wireType) {
44
+ return encodeVarint((BigInt(field) << 3n) | BigInt(wireType))
45
+ }
46
+
47
+ export function encodeStringField(field, value) {
48
+ const bytes = Buffer.from(String(value), 'utf8')
49
+ return Buffer.concat([fieldKey(field, 2), encodeVarint(bytes.length), bytes])
50
+ }
51
+
52
+ export function encodeMessageField(field, bytes) {
53
+ return Buffer.concat([fieldKey(field, 2), encodeVarint(bytes.length), bytes])
54
+ }
55
+
56
+ export function encodeBoolField(field, value) {
57
+ return Buffer.concat([fieldKey(field, 0), encodeVarint(value ? 1 : 0)])
58
+ }
59
+
60
+ /** Decode one message into `{ field, wire, value }` entries (value is BigInt for varints). */
61
+ export function decodeFields(buffer) {
62
+ const fields = []
63
+ let position = 0
64
+ while (position < buffer.length) {
65
+ let tag
66
+ ;[tag, position] = readVarint(buffer, position)
67
+ const field = Number(tag >> 3n)
68
+ const wire = Number(tag & 7n)
69
+ if (wire === 0) {
70
+ let value
71
+ ;[value, position] = readVarint(buffer, position)
72
+ fields.push({ field, wire, value })
73
+ } else if (wire === 2) {
74
+ let length
75
+ ;[length, position] = readVarint(buffer, position)
76
+ const end = position + Number(length)
77
+ if (end > buffer.length) throw new Error('protobuf: truncated length-delimited field')
78
+ fields.push({ field, wire, value: buffer.subarray(position, end) })
79
+ position = end
80
+ } else if (wire === 1) {
81
+ position += 8
82
+ } else if (wire === 5) {
83
+ position += 4
84
+ } else {
85
+ throw new Error(`protobuf: unsupported wire type ${wire}`)
86
+ }
87
+ }
88
+ return fields
89
+ }
90
+
91
+ function stringOf(fields, field) {
92
+ const entry = fields.find((item) => item.field === field && item.wire === 2)
93
+ return entry === undefined ? undefined : entry.value.toString('utf8')
94
+ }
95
+
96
+ function messageOf(fields, field) {
97
+ const entry = fields.find((item) => item.field === field && item.wire === 2)
98
+ return entry === undefined ? undefined : entry.value
99
+ }
100
+
101
+ export function encodeOverrideBalancer({ balancerTag, target }) {
102
+ return Buffer.concat([encodeStringField(1, balancerTag), encodeStringField(2, target)])
103
+ }
104
+
105
+ export function encodeGetBalancerInfo({ tag }) {
106
+ return encodeStringField(1, tag)
107
+ }
108
+
109
+ export function decodeBalancerInfo(buffer) {
110
+ const balancer = messageOf(decodeFields(buffer), 1)
111
+ if (balancer === undefined) return { overrideTarget: undefined, principleTargets: [] }
112
+ const fields = decodeFields(balancer)
113
+ const override = messageOf(fields, 5)
114
+ const principle = messageOf(fields, 6)
115
+ return {
116
+ overrideTarget: override === undefined ? undefined : stringOf(decodeFields(override), 2),
117
+ principleTargets:
118
+ principle === undefined
119
+ ? []
120
+ : decodeFields(principle)
121
+ .filter((item) => item.field === 1 && item.wire === 2)
122
+ .map((item) => item.value.toString('utf8')),
123
+ }
124
+ }
125
+
126
+ export function encodeQueryStats({ patterns = [], reset = false }) {
127
+ const parts = patterns.map((pattern) => encodeStringField(1, pattern))
128
+ if (reset) parts.push(encodeBoolField(2, true))
129
+ return Buffer.concat(parts)
130
+ }
131
+
132
+ export function decodeQueryStats(buffer) {
133
+ const stats = []
134
+ for (const entry of decodeFields(buffer)) {
135
+ if (entry.field !== 1 || entry.wire !== 2) continue
136
+ const fields = decodeFields(entry.value)
137
+ const name = stringOf(fields, 1)
138
+ const value = fields.find((item) => item.field === 2 && item.wire === 0)?.value ?? 0n
139
+ if (name === undefined) continue
140
+ stats.push({ name, value: Number(value) })
141
+ }
142
+ return stats
143
+ }
package/lib/health.js ADDED
@@ -0,0 +1,109 @@
1
+ import net from 'node:net'
2
+ import http from 'node:http'
3
+ import { setTimeout as delay } from 'node:timers/promises'
4
+
5
+ /** One TCP connect attempt; resolves false on error or timeout, never throws. */
6
+ export function tcpConnect(host, port, timeoutMs) {
7
+ return new Promise((resolve) => {
8
+ let settled = false
9
+ const socket = net.connect({ host, port })
10
+ const finish = (ok) => {
11
+ if (settled) return
12
+ settled = true
13
+ socket.destroy()
14
+ resolve(ok)
15
+ }
16
+ socket.setTimeout(Math.max(50, timeoutMs), () => finish(false))
17
+ socket.once('connect', () => finish(true))
18
+ socket.once('error', () => finish(false))
19
+ })
20
+ }
21
+
22
+ /** Sleep that returns early when the signal aborts. */
23
+ export async function cancellableDelay(ms, signal) {
24
+ if (!signal) return delay(ms)
25
+ if (signal.aborted) return
26
+ await Promise.race([
27
+ delay(ms),
28
+ new Promise((resolve) => {
29
+ signal.addEventListener('abort', resolve, { once: true })
30
+ }),
31
+ ])
32
+ }
33
+
34
+ /**
35
+ * Poll the inbound port until it accepts a connection or the budget runs out.
36
+ * This is the hard readiness gate: the global dispatcher is never pointed at a
37
+ * port that has not answered a real TCP connect.
38
+ */
39
+ export async function waitForPort({ host, port, timeoutMs, intervalMs = 100, signal }) {
40
+ const deadline = Date.now() + timeoutMs
41
+ while (Date.now() < deadline) {
42
+ if (signal?.aborted) return false
43
+ if (await tcpConnect(host, port, 500)) return true
44
+ await cancellableDelay(intervalMs, signal)
45
+ }
46
+ return false
47
+ }
48
+
49
+ /** Whether a loopback port can be bound right now. */
50
+ export function portIsFree(host, port) {
51
+ return new Promise((resolve) => {
52
+ const server = net.createServer()
53
+ server.unref()
54
+ server.once('error', () => resolve(false))
55
+ server.listen({ host, port, exclusive: true }, () => {
56
+ server.close(() => resolve(true))
57
+ })
58
+ })
59
+ }
60
+
61
+ /**
62
+ * End-to-end probe: ask the local inbound for a small URL through the proxy
63
+ * and require a non-error status. This is the only check that proves the
64
+ * upstream (uuid / flow / SNI / shortId / key) actually works; a TCP connect
65
+ * alone only proves the inbound is listening.
66
+ *
67
+ * @returns {Promise<{ ok: boolean, status?: number, error?: string }>}
68
+ */
69
+ export function httpProxyProbe({ host, port, url, timeoutMs }) {
70
+ return new Promise((resolve) => {
71
+ let target
72
+ try {
73
+ target = new URL(url)
74
+ } catch (error) {
75
+ resolve({ ok: false, error: `invalid probe url: ${String(error)}` })
76
+ return
77
+ }
78
+ let settled = false
79
+ const finish = (value) => {
80
+ if (settled) return
81
+ settled = true
82
+ resolve(value)
83
+ }
84
+ const request = http.request(
85
+ {
86
+ host,
87
+ port,
88
+ method: 'GET',
89
+ // Absolute-form request line: the HTTP inbound proxies this.
90
+ path: target.href,
91
+ headers: { host: target.host, connection: 'close' },
92
+ timeout: timeoutMs,
93
+ },
94
+ (response) => {
95
+ response.resume()
96
+ const status = response.statusCode ?? 0
97
+ finish({ ok: status >= 200 && status < 400, status })
98
+ },
99
+ )
100
+ request.on('timeout', () => {
101
+ request.destroy(new Error('probe timeout'))
102
+ finish({ ok: false, error: 'timeout' })
103
+ })
104
+ request.on('error', (error) => {
105
+ finish({ ok: false, error: error?.code ?? error?.message ?? 'request failed' })
106
+ })
107
+ request.end()
108
+ })
109
+ }
package/lib/log.js ADDED
@@ -0,0 +1,20 @@
1
+ /** Thin adapter over the cordis logger so tests can run without a context. */
2
+ export function createLogger(ctx) {
3
+ const base = ctx?.logger
4
+ const emit = (level, args) => {
5
+ try {
6
+ if (base && typeof base[level] === 'function') base[level](...args)
7
+ else if (level === 'debug') console.log(...args)
8
+ else console[level](...args)
9
+ } catch {
10
+ // a logger failure must never break the plugin
11
+ }
12
+ }
13
+ return {
14
+ info: (...args) => emit('info', args),
15
+ warn: (...args) => emit('warn', args),
16
+ error: (...args) => emit('error', args),
17
+ debug: (...args) => emit('debug', args),
18
+ raw: base,
19
+ }
20
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ /**
7
+ * The harness home. `DSH_HOME` is the documented override; the desktop app
8
+ * keeps everything under `~/.dsh`.
9
+ */
10
+ export function resolveDshHome() {
11
+ const fromEnv = process.env.DSH_HOME?.trim()
12
+ if (fromEnv) return fromEnv
13
+ return join(homedir(), '.dsh')
14
+ }
15
+
16
+ /**
17
+ * Resolve the plugin data directory and create it with restrictive
18
+ * permissions. The directory holds the generated xray config, which carries
19
+ * the VLESS uuid and the REALITY key.
20
+ *
21
+ * @param {object} config - normalized config.
22
+ * @returns {string} absolute data directory.
23
+ */
24
+ export function resolveDataDir(config) {
25
+ const dir = config.binary.dataDir?.trim()
26
+ ? config.binary.dataDir.trim()
27
+ : join(resolveDshHome(), 'xray-proxy')
28
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
29
+ try {
30
+ chmodSync(dir, 0o700)
31
+ } catch {
32
+ // Windows ignores POSIX modes; access is governed by the user's profile.
33
+ }
34
+ return dir
35
+ }
36
+
37
+ export function atomicWriteFile(file, content, mode = 0o600) {
38
+ const dir = dirname(file)
39
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
40
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`
41
+ try {
42
+ writeFileSync(temp, content, { mode })
43
+ try {
44
+ chmodSync(temp, mode)
45
+ } catch {
46
+ // see resolveDataDir
47
+ }
48
+ renameSync(temp, file)
49
+ } catch (error) {
50
+ try {
51
+ rmSync(temp, { force: true })
52
+ } catch {
53
+ // best effort
54
+ }
55
+ throw error
56
+ }
57
+ }
58
+
59
+ export function atomicWriteJson(file, value, mode = 0o600) {
60
+ atomicWriteFile(file, `${JSON.stringify(value, null, 2)}\n`, mode)
61
+ }
62
+
63
+ export function sha256(text) {
64
+ return createHash('sha256').update(text).digest('hex')
65
+ }
package/lib/process.js ADDED
@@ -0,0 +1,197 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+
4
+ const COLLECT_BYTES = 64 * 1024
5
+
6
+ /**
7
+ * Resolve the subprocess service without tripping Cordis' inject guard.
8
+ *
9
+ * Reading `ctx.subprocess` directly throws
10
+ * `cannot get property "subprocess" without inject` on a fiber that did not
11
+ * declare it; `ctx.get(name)` is the reflective lookup and returns undefined
12
+ * instead. The service stays optional because a profile without
13
+ * `dsh-subprocess-local` still gets the `child_process` fallback.
14
+ */
15
+ function resolveSubprocess(ctx) {
16
+ if (!ctx) return undefined
17
+ try {
18
+ if (typeof ctx.get === 'function') {
19
+ const service = ctx.get('subprocess')
20
+ if (service) return service
21
+ }
22
+ } catch {
23
+ // inactive or guarded context: fall through to the fallback launcher
24
+ }
25
+ return undefined
26
+ }
27
+
28
+ /**
29
+ * A launcher hides whether the child runs through `ctx.subprocess` (Windows Job
30
+ * Object, process-tree containment, DSH's environment scrub) or through a bare
31
+ * `child_process` fallback for compositions without the service.
32
+ *
33
+ * Every returned handle has the same small surface:
34
+ * done: Promise<{ exitCode, signal, error? }>
35
+ * stderrTail(): string
36
+ * terminate(): Promise<void>
37
+ * waitForExit(): Promise<void>
38
+ * pid?: number
39
+ */
40
+ export function createLauncher(ctx, { logger } = {}) {
41
+ const service = resolveSubprocess(ctx)
42
+ if (service && typeof service.spawn === 'function' && typeof service.resolveExecutable === 'function') {
43
+ return createServiceLauncher(service)
44
+ }
45
+ logger?.warn?.('ctx.subprocess 不可用,退化为 child_process 启动;Windows 上将无法保证 Job Object 级别的进程树清理')
46
+ return createNodeLauncher()
47
+ }
48
+
49
+ /**
50
+ * Resolve the launcher lazily at first spawn instead of at activation: the
51
+ * subprocess provider may mount after this plugin's fiber, and a strict
52
+ * `inject` dependency would stop the plugin from activating at all.
53
+ */
54
+ export function createLazyLauncher(ctx, options) {
55
+ let inner
56
+ const resolve = () => (inner ??= createLauncher(ctx, options))
57
+ return {
58
+ get kind() {
59
+ return resolve().kind
60
+ },
61
+ resolveExecutable(command, env, signal) {
62
+ return resolve().resolveExecutable(command, env, signal)
63
+ },
64
+ spawn(spec) {
65
+ return resolve().spawn(spec)
66
+ },
67
+ }
68
+ }
69
+
70
+ function createServiceLauncher(service) {
71
+ return {
72
+ kind: 'ctx.subprocess',
73
+ resolveExecutable(command) {
74
+ return service.resolveExecutable(command, process.env, undefined)
75
+ },
76
+ spawn({ argv, cwd, graceMs, env }) {
77
+ const handle = service.spawn({
78
+ argv,
79
+ cwd,
80
+ stdio: {
81
+ stdin: 'ignore',
82
+ stdout: { maxBytes: COLLECT_BYTES },
83
+ stderr: { maxBytes: COLLECT_BYTES },
84
+ },
85
+ graceMs,
86
+ env,
87
+ })
88
+ const done = Promise.resolve(handle.done).then(
89
+ (outcome) => ({ exitCode: outcome?.exitCode ?? null, signal: outcome?.signal ?? null }),
90
+ (error) => ({ exitCode: null, signal: null, error }),
91
+ )
92
+ const readTail = (which) => {
93
+ try {
94
+ return handle.collected?.[which]?.readFrom(0)?.text ?? ''
95
+ } catch {
96
+ return ''
97
+ }
98
+ }
99
+ return {
100
+ kind: 'ctx.subprocess',
101
+ get pid() {
102
+ return typeof handle.pid === 'number' ? handle.pid : undefined
103
+ },
104
+ done,
105
+ stdoutTail: () => readTail('stdout'),
106
+ stderrTail: () => readTail('stderr'),
107
+ terminate() {
108
+ return Promise.resolve(handle.terminate()).catch(() => {})
109
+ },
110
+ waitForExit() {
111
+ return Promise.resolve(handle.waitForExit()).catch(() => {})
112
+ },
113
+ }
114
+ },
115
+ }
116
+ }
117
+
118
+ function createNodeLauncher() {
119
+ return {
120
+ kind: 'child_process',
121
+ async resolveExecutable(command) {
122
+ return command
123
+ },
124
+ spawn({ argv, cwd, graceMs, env }) {
125
+ const child = spawn(argv[0], argv.slice(1), {
126
+ cwd,
127
+ env: env ?? process.env,
128
+ stdio: ['ignore', 'pipe', 'pipe'],
129
+ windowsHide: true,
130
+ })
131
+ let stderr = ''
132
+ let stdout = ''
133
+ child.stderr?.on('data', (chunk) => {
134
+ stderr += chunk.toString('utf8')
135
+ if (stderr.length > COLLECT_BYTES) stderr = stderr.slice(-COLLECT_BYTES)
136
+ })
137
+ child.stdout?.on('data', (chunk) => {
138
+ stdout += chunk.toString('utf8')
139
+ if (stdout.length > COLLECT_BYTES) stdout = stdout.slice(-COLLECT_BYTES)
140
+ })
141
+
142
+ let settled = false
143
+ let resolveExit
144
+ const exited = new Promise((resolve) => {
145
+ resolveExit = resolve
146
+ })
147
+ const settle = (value) => {
148
+ if (settled) return
149
+ settled = true
150
+ resolveExit(value)
151
+ }
152
+ child.once('error', (error) => settle({ exitCode: null, signal: null, error }))
153
+ child.once('exit', (code, signal) => settle({ exitCode: code, signal }))
154
+
155
+ let terminating
156
+ return {
157
+ kind: 'child_process',
158
+ get pid() {
159
+ return child.pid
160
+ },
161
+ done: exited,
162
+ stdoutTail: () => stdout,
163
+ stderrTail: () => stderr,
164
+ terminate() {
165
+ if (terminating) return terminating
166
+ terminating = (async () => {
167
+ if (child.exitCode !== null || child.signalCode !== null) return
168
+ if (process.platform === 'win32') {
169
+ if (typeof child.pid === 'number') {
170
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true })
171
+ }
172
+ await Promise.race([exited, delay(1500).then(() => {})])
173
+ return
174
+ }
175
+ try {
176
+ child.kill('SIGTERM')
177
+ } catch {
178
+ // already gone
179
+ }
180
+ const exitedInTime = await Promise.race([exited.then(() => true), delay(graceMs).then(() => false)])
181
+ if (!exitedInTime) {
182
+ try {
183
+ child.kill('SIGKILL')
184
+ } catch {
185
+ // already gone
186
+ }
187
+ }
188
+ })()
189
+ return terminating
190
+ },
191
+ waitForExit() {
192
+ return exited.then(() => {})
193
+ },
194
+ }
195
+ },
196
+ }
197
+ }