@twinsearth/dsh-2xray 0.0.0-stage → 0.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +29 -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 +154 -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/route.test.js +158 -0
  42. package/test/share-link.test.js +113 -0
  43. package/test/supervisor.test.js +525 -0
  44. package/test/tools.test.js +88 -0
  45. 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,154 @@
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
+ * A launcher hides whether the child runs through `ctx.subprocess` (Windows Job
8
+ * Object, process-tree containment, DSH's environment scrub) or through a bare
9
+ * `child_process` fallback for compositions without the service.
10
+ *
11
+ * Every returned handle has the same small surface:
12
+ * done: Promise<{ exitCode, signal, error? }>
13
+ * stderrTail(): string
14
+ * terminate(): Promise<void>
15
+ * waitForExit(): Promise<void>
16
+ * pid?: number
17
+ */
18
+ export function createLauncher(ctx, { logger } = {}) {
19
+ const service = ctx?.subprocess
20
+ if (service && typeof service.spawn === 'function' && typeof service.resolveExecutable === 'function') {
21
+ return createServiceLauncher(service)
22
+ }
23
+ logger?.warn?.('ctx.subprocess 不可用,退化为 child_process 启动;Windows 上将无法保证 Job Object 级别的进程树清理')
24
+ return createNodeLauncher()
25
+ }
26
+
27
+ function createServiceLauncher(service) {
28
+ return {
29
+ kind: 'ctx.subprocess',
30
+ resolveExecutable(command) {
31
+ return service.resolveExecutable(command, process.env, undefined)
32
+ },
33
+ spawn({ argv, cwd, graceMs, env }) {
34
+ const handle = service.spawn({
35
+ argv,
36
+ cwd,
37
+ stdio: {
38
+ stdin: 'ignore',
39
+ stdout: { maxBytes: COLLECT_BYTES },
40
+ stderr: { maxBytes: COLLECT_BYTES },
41
+ },
42
+ graceMs,
43
+ env,
44
+ })
45
+ const done = Promise.resolve(handle.done).then(
46
+ (outcome) => ({ exitCode: outcome?.exitCode ?? null, signal: outcome?.signal ?? null }),
47
+ (error) => ({ exitCode: null, signal: null, error }),
48
+ )
49
+ const readTail = (which) => {
50
+ try {
51
+ return handle.collected?.[which]?.readFrom(0)?.text ?? ''
52
+ } catch {
53
+ return ''
54
+ }
55
+ }
56
+ return {
57
+ kind: 'ctx.subprocess',
58
+ get pid() {
59
+ return typeof handle.pid === 'number' ? handle.pid : undefined
60
+ },
61
+ done,
62
+ stdoutTail: () => readTail('stdout'),
63
+ stderrTail: () => readTail('stderr'),
64
+ terminate() {
65
+ return Promise.resolve(handle.terminate()).catch(() => {})
66
+ },
67
+ waitForExit() {
68
+ return Promise.resolve(handle.waitForExit()).catch(() => {})
69
+ },
70
+ }
71
+ },
72
+ }
73
+ }
74
+
75
+ function createNodeLauncher() {
76
+ return {
77
+ kind: 'child_process',
78
+ async resolveExecutable(command) {
79
+ return command
80
+ },
81
+ spawn({ argv, cwd, graceMs, env }) {
82
+ const child = spawn(argv[0], argv.slice(1), {
83
+ cwd,
84
+ env: env ?? process.env,
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ windowsHide: true,
87
+ })
88
+ let stderr = ''
89
+ let stdout = ''
90
+ child.stderr?.on('data', (chunk) => {
91
+ stderr += chunk.toString('utf8')
92
+ if (stderr.length > COLLECT_BYTES) stderr = stderr.slice(-COLLECT_BYTES)
93
+ })
94
+ child.stdout?.on('data', (chunk) => {
95
+ stdout += chunk.toString('utf8')
96
+ if (stdout.length > COLLECT_BYTES) stdout = stdout.slice(-COLLECT_BYTES)
97
+ })
98
+
99
+ let settled = false
100
+ let resolveExit
101
+ const exited = new Promise((resolve) => {
102
+ resolveExit = resolve
103
+ })
104
+ const settle = (value) => {
105
+ if (settled) return
106
+ settled = true
107
+ resolveExit(value)
108
+ }
109
+ child.once('error', (error) => settle({ exitCode: null, signal: null, error }))
110
+ child.once('exit', (code, signal) => settle({ exitCode: code, signal }))
111
+
112
+ let terminating
113
+ return {
114
+ kind: 'child_process',
115
+ get pid() {
116
+ return child.pid
117
+ },
118
+ done: exited,
119
+ stdoutTail: () => stdout,
120
+ stderrTail: () => stderr,
121
+ terminate() {
122
+ if (terminating) return terminating
123
+ terminating = (async () => {
124
+ if (child.exitCode !== null || child.signalCode !== null) return
125
+ if (process.platform === 'win32') {
126
+ if (typeof child.pid === 'number') {
127
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true })
128
+ }
129
+ await Promise.race([exited, delay(1500).then(() => {})])
130
+ return
131
+ }
132
+ try {
133
+ child.kill('SIGTERM')
134
+ } catch {
135
+ // already gone
136
+ }
137
+ const exitedInTime = await Promise.race([exited.then(() => true), delay(graceMs).then(() => false)])
138
+ if (!exitedInTime) {
139
+ try {
140
+ child.kill('SIGKILL')
141
+ } catch {
142
+ // already gone
143
+ }
144
+ }
145
+ })()
146
+ return terminating
147
+ },
148
+ waitForExit() {
149
+ return exited.then(() => {})
150
+ },
151
+ }
152
+ },
153
+ }
154
+ }