@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
package/lib/tools.js ADDED
@@ -0,0 +1,106 @@
1
+ import { loadHarnessModule } from './route.js'
2
+
3
+ /**
4
+ * Model-facing tools for the control plane. Registered lazily and optionally:
5
+ * a profile without the `tools` service, or a layout where `@deepseek-ai/dsh-tools`
6
+ * cannot be resolved, keeps the proxy working and just skips the tools.
7
+ *
8
+ * `defineTool` is imported from the harness installation because the tool
9
+ * registry validates and renders through that exact module instance.
10
+ */
11
+ export async function installTools({ ctx, supervisor, logger, loadToolsModule = loadHarnessModule }) {
12
+ if (typeof ctx.inject !== 'function') {
13
+ logger?.debug?.('ctx.inject 不可用,跳过 xray_* 工具注册')
14
+ return false
15
+ }
16
+ const found = await loadToolsModule('@deepseek-ai/dsh-tools')
17
+ const defineTool = found?.module?.defineTool
18
+ if (typeof defineTool !== 'function') {
19
+ logger?.warn?.('找不到 @deepseek-ai/dsh-tools,xray_* 工具未注册;插件其余功能不受影响')
20
+ return false
21
+ }
22
+
23
+ ctx.inject(['tools'], (child) => {
24
+ try {
25
+ child.tools.register(
26
+ defineTool({
27
+ name: 'xray_status',
28
+ description:
29
+ 'Show the Xray proxy supervisor status: process state, route takeover, functional probe, active node and control-plane reachability. Read-only.',
30
+ parameters: {},
31
+ output: {
32
+ schema: { type: 'string' },
33
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
34
+ },
35
+ async execute() {
36
+ return JSON.stringify(supervisor.snapshot(), null, 2)
37
+ },
38
+ }),
39
+ )
40
+
41
+ child.tools.register(
42
+ defineTool({
43
+ name: 'xray_validate_config',
44
+ description:
45
+ 'Validate the Xray config generated from the current settings with `xray run -test`, without starting, stopping or restarting the proxy. Read-only pre-flight; returns { ok, detail, configHash }.',
46
+ parameters: {},
47
+ output: {
48
+ schema: { type: 'string' },
49
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
50
+ },
51
+ async execute() {
52
+ return JSON.stringify(await supervisor.validateConfig())
53
+ },
54
+ }),
55
+ )
56
+
57
+ child.tools.register(
58
+ defineTool({
59
+ name: 'xray_switch_node',
60
+ description:
61
+ 'Switch the active Xray outbound to a configured node without restarting the process. The local inbound and the harness proxy policy stay untouched. Returns the pinned outbound tag.',
62
+ parameters: {
63
+ node: {
64
+ type: 'string',
65
+ required: true,
66
+ description: 'Node id or outbound tag from the plugin config, e.g. "hk-01" or "proxy-hk-01".',
67
+ },
68
+ },
69
+ output: {
70
+ schema: { type: 'string' },
71
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
72
+ },
73
+ async execute(args) {
74
+ const activeNode = await supervisor.switchNode(args.node)
75
+ return JSON.stringify({ activeNode })
76
+ },
77
+ }),
78
+ )
79
+
80
+ child.tools.register(
81
+ defineTool({
82
+ name: 'xray_stats',
83
+ description:
84
+ 'Read cumulative traffic counters per configured node (uplink/downlink bytes since Xray started). Counters are not a rate; pass reset=true to zero them after reading.',
85
+ parameters: {
86
+ reset: {
87
+ type: 'boolean',
88
+ description: 'Reset the counters after this read. Defaults to false.',
89
+ },
90
+ },
91
+ output: {
92
+ schema: { type: 'string' },
93
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
94
+ },
95
+ async execute(args) {
96
+ return JSON.stringify(await supervisor.queryStats({ reset: args.reset === true }))
97
+ },
98
+ }),
99
+ )
100
+ logger?.info?.('已注册 xray_status / xray_switch_node / xray_stats 工具')
101
+ } catch (error) {
102
+ logger?.warn?.(`注册 xray_* 工具失败:${String(error)}`)
103
+ }
104
+ })
105
+ return true
106
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Pure builder for the xray-core client config. No I/O, no logging of secrets:
3
+ * the caller writes the result to disk and only ever logs its hash.
4
+ *
5
+ * Single node (the legacy `server.*` shape): one VLESS outbound and a
6
+ * catch-all routing rule straight to it — the same config this plugin has
7
+ * always produced.
8
+ *
9
+ * Multiple nodes (`nodes[]`): one outbound per node plus a balancer. The
10
+ * balancer is the runtime switch point: `RoutingService.OverrideBalancerTarget`
11
+ * pins it to one outbound without touching the process. Outbound configs are
12
+ * static because `HandlerService.AddOutbound` would require protobuf-encoding
13
+ * `OutboundHandlerConfig` (TypedMessage sender/proxy settings) on the Node
14
+ * side, which is far more fragile than a two-string override.
15
+ *
16
+ * The control plane (`control.enabled`) adds the standard API inbound plus the
17
+ * `api` routing rule, and optionally the stats counters.
18
+ */
19
+
20
+ const PRIVATE_IP_RULES = ['geoip:private']
21
+ const API_TAG = 'api'
22
+
23
+ function escapeRegExp(value) {
24
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
25
+ }
26
+
27
+ function buildOutbound(node) {
28
+ const users = [{ id: node.uuid, encryption: 'none' }]
29
+ if (node.flow) users[0].flow = node.flow
30
+
31
+ const streamSettings = {
32
+ network: node.network === 'tcp' ? 'raw' : node.network,
33
+ security: node.security,
34
+ }
35
+
36
+ if (node.security === 'reality') {
37
+ streamSettings.realitySettings = {
38
+ serverName: node.serverName,
39
+ fingerprint: node.fingerprint,
40
+ shortId: node.shortId,
41
+ spiderX: node.spiderX,
42
+ show: false,
43
+ }
44
+ // Write both spellings: current Xray reads `password` and new configs use
45
+ // it, older builds still parse `publicKey`. Never write both empty.
46
+ if (node.password) streamSettings.realitySettings.password = node.password
47
+ if (node.publicKey) streamSettings.realitySettings.publicKey = node.publicKey
48
+ } else if (node.security === 'tls') {
49
+ streamSettings.tlsSettings = {
50
+ serverName: node.serverName,
51
+ fingerprint: node.fingerprint,
52
+ }
53
+ }
54
+
55
+ return {
56
+ tag: node.tag,
57
+ protocol: 'vless',
58
+ settings: {
59
+ vnext: [
60
+ {
61
+ address: node.address,
62
+ port: node.port,
63
+ users,
64
+ },
65
+ ],
66
+ },
67
+ streamSettings,
68
+ // Vision already carries XUDP; mux only adds latency here.
69
+ mux: { enabled: false },
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Build the xray-core JSON config.
75
+ *
76
+ * @param {object} config - normalized config from `normalizeConfig()`.
77
+ * @returns {object} xray-core configuration object.
78
+ */
79
+ export function buildXrayConfig(config) {
80
+ const { inbound, routing, log, control } = config
81
+ const nodes = config.nodes
82
+
83
+ const inbounds = []
84
+ const primaryProtocol = inbound.protocol === 'socks' ? 'socks' : 'http'
85
+ inbounds.push({
86
+ tag: `${primaryProtocol}-in`,
87
+ listen: inbound.host,
88
+ port: inbound.port,
89
+ protocol: primaryProtocol,
90
+ settings: primaryProtocol === 'socks' ? { auth: 'noauth', udp: false } : {},
91
+ sniffing:
92
+ primaryProtocol === 'socks'
93
+ ? { enabled: true, destOverride: ['http', 'tls'], routeOnly: false }
94
+ : { enabled: false },
95
+ })
96
+
97
+ if (inbound.socks.enabled) {
98
+ inbounds.push({
99
+ tag: 'socks-in',
100
+ listen: inbound.socks.host,
101
+ port: inbound.socks.port,
102
+ protocol: 'socks',
103
+ settings: { auth: 'noauth', udp: inbound.socks.udp },
104
+ // Sniffing only ever belongs to an inbound. `routeOnly: false` resets the
105
+ // destination to the recovered domain so the proxy outbound can resolve
106
+ // it remotely instead of the client resolving it locally.
107
+ sniffing: { enabled: true, destOverride: ['http', 'tls'], routeOnly: false },
108
+ })
109
+ }
110
+
111
+ if (control.enabled) {
112
+ inbounds.push({
113
+ tag: API_TAG,
114
+ listen: control.host,
115
+ port: control.port,
116
+ protocol: 'dokodemo-door',
117
+ settings: { address: '127.0.0.1' },
118
+ })
119
+ }
120
+
121
+ const proxyOutbounds = nodes.map((node) => buildOutbound(node))
122
+ const outbounds = [
123
+ ...proxyOutbounds,
124
+ {
125
+ tag: 'direct',
126
+ protocol: 'freedom',
127
+ settings: {},
128
+ streamSettings: {
129
+ sockopt: {
130
+ // Direct domains resolve through the system resolver, which is where
131
+ // split-horizon/VPN/internal DNS lives. Proxy traffic keeps local DNS
132
+ // at zero because the proxy outbound resolves remotely.
133
+ domainStrategy: 'AsIs',
134
+ },
135
+ },
136
+ },
137
+ { tag: 'block', protocol: 'blackhole' },
138
+ ]
139
+
140
+ const rules = []
141
+ if (control.enabled) {
142
+ rules.push({ type: 'field', inboundTag: [API_TAG], outboundTag: API_TAG })
143
+ }
144
+ if (routing.blockAds) {
145
+ rules.push({ type: 'field', domain: ['geosite:category-ads-all'], outboundTag: 'block' })
146
+ }
147
+ rules.push({ type: 'field', ip: PRIVATE_IP_RULES, outboundTag: 'direct' })
148
+
149
+ const bypassDomains = dedupe(routing.bypassDomains)
150
+ if (bypassDomains.length > 0) {
151
+ // A trailing dot entry matches the domain and every subdomain in Xray's
152
+ // domain matcher; `geosite:cn` is opt-in because a catch-all would
153
+ // otherwise make the second routing pass unreachable.
154
+ rules.push({ type: 'field', domain: bypassDomains.map((entry) => `domain:${entry}`), outboundTag: 'direct' })
155
+ }
156
+
157
+ if (routing.geositeCn) {
158
+ rules.push({ type: 'field', domain: ['geosite:cn'], outboundTag: 'direct' })
159
+ }
160
+
161
+ const balancers = []
162
+ if (nodes.length > 1) {
163
+ balancers.push({
164
+ tag: control.balancerTag,
165
+ // Every node is a candidate; the runtime override pins one, and until an
166
+ // override lands the strategy picks deterministically enough to be
167
+ // usable.
168
+ selector: nodes.map((node) => `^${escapeRegExp(node.tag)}$`),
169
+ strategy: { type: 'random' },
170
+ })
171
+ rules.push({ type: 'field', network: 'tcp,udp', balancerTag: control.balancerTag })
172
+ } else {
173
+ rules.push({ type: 'field', network: 'tcp,udp', outboundTag: nodes[0].tag })
174
+ }
175
+
176
+ const policy = {
177
+ levels: { '0': { handshake: 8, connIdle: 600 } },
178
+ }
179
+ if (control.enabled && control.stats) {
180
+ policy.system = {
181
+ statsInboundUplink: true,
182
+ statsInboundDownlink: true,
183
+ statsOutboundUplink: true,
184
+ statsOutboundDownlink: true,
185
+ }
186
+ }
187
+
188
+ const built = {
189
+ log: {
190
+ loglevel: log.level,
191
+ access: 'none',
192
+ dnsLog: false,
193
+ },
194
+ // Keep connections alive past the harness' 300s stream idle watchdog so
195
+ // Xray never sends the clean FIN that becomes a non-retryable
196
+ // STREAM_CLOSED in the model provider.
197
+ policy,
198
+ inbounds,
199
+ outbounds,
200
+ routing: {
201
+ domainStrategy: 'AsIs',
202
+ rules,
203
+ ...(balancers.length > 0 ? { balancers } : {}),
204
+ },
205
+ }
206
+
207
+ if (control.enabled) {
208
+ built.api = { tag: API_TAG, services: control.services }
209
+ if (control.stats) built.stats = {}
210
+ }
211
+
212
+ return built
213
+ }
214
+
215
+ function dedupe(entries) {
216
+ const seen = new Set()
217
+ const out = []
218
+ for (const raw of entries ?? []) {
219
+ const entry = String(raw).trim()
220
+ if (!entry || seen.has(entry)) continue
221
+ seen.add(entry)
222
+ out.push(entry)
223
+ }
224
+ return out
225
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Xray Proxy",
4
+ "description": "Supervises an xray-core child process and routes the harness' global HTTP(S) traffic through its local inbound."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Xray 代理",
4
+ "description": "托管 xray-core 子进程,并把 DSH 进程内的全局 HTTP(S) 流量导入它的本地入站。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@twinsearth/dsh-2xray",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "description": "DSH-2Xray: supervises xray-core and routes DSH process-wide HTTP(S) traffic through its local inbound, with optional multi-node gRPC control plane",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./package.json": "./package.json",
11
+ "./cordis.patch.yml": "./cordis.patch.yml",
12
+ "./locale/*.json": "./locale/*.json",
13
+ "./icon": "./icon.svg"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "lib/*.js",
18
+ "lib/control/*.js",
19
+ "cordis.patch.yml",
20
+ "locale/*.json",
21
+ "icon.svg",
22
+ "bin/*",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE",
26
+ "docs/*.md",
27
+ "examples/*.json",
28
+ "scripts/*.mjs",
29
+ "test/*.js"
30
+ ],
31
+ "scripts": {
32
+ "test": "node --test --test-isolation=none test/*.test.js",
33
+ "check": "node scripts/release-check.mjs"
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "license": "MIT",
39
+ "dsh": {
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build a config for a JSON config file and validate it with a real xray binary:
4
+ *
5
+ * node scripts/check-config.mjs --config my.json --xray C:\path\to\xray.exe
6
+ *
7
+ * `--security none --network tcp --flow ""` in the JSON downgrades the stream
8
+ * for old binaries that predate REALITY. The generated file is written to the
9
+ * OS temp directory and printed so the exact bytes can be inspected.
10
+ */
11
+ import { mkdtempSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { spawnSync } from 'node:child_process'
15
+ import { normalizeConfig } from '../lib/config.js'
16
+ import { buildXrayConfig } from '../lib/xray-config.js'
17
+
18
+ const args = process.argv.slice(2)
19
+ const getArg = (name) => {
20
+ const index = args.indexOf(name)
21
+ return index === -1 ? undefined : args[index + 1]
22
+ }
23
+
24
+ const configPath = getArg('--config')
25
+ const xray = getArg('--xray') ?? process.env.XRAY_BIN
26
+ if (!configPath || !xray) {
27
+ console.error('usage: node scripts/check-config.mjs --config <json> --xray <path>')
28
+ process.exit(2)
29
+ }
30
+
31
+ const { readFileSync } = await import('node:fs')
32
+ const raw = JSON.parse(readFileSync(configPath, 'utf8'))
33
+ const result = normalizeConfig(raw)
34
+ if (result.errors.length > 0) {
35
+ for (const error of result.errors) console.error(`${error.field}: ${error.message}`)
36
+ process.exit(3)
37
+ }
38
+
39
+ const built = buildXrayConfig(result.config)
40
+ if (args.includes('--legacy')) {
41
+ // Pre-1.8 binaries call the raw transport "tcp" and reject "raw".
42
+ for (const outbound of built.outbounds) {
43
+ if (outbound.streamSettings?.network === 'raw') outbound.streamSettings.network = 'tcp'
44
+ }
45
+ }
46
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-xray-check-'))
47
+ const file = join(dir, 'xray-config.json')
48
+ writeFileSync(file, `${JSON.stringify(built, null, 2)}\n`, { mode: 0o600 })
49
+ console.log(`config: ${file}`)
50
+
51
+ const outcome = spawnSync(xray, ['run', '-test', '-c', file], { stdio: 'inherit' })
52
+ if (outcome.status !== 0) {
53
+ console.error(`xray run -test exited ${outcome.status ?? outcome.error?.code ?? 'unknown'}`)
54
+ process.exit(1)
55
+ }
56
+ console.log('Configuration OK.')
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Convert one or more `vless://` share links (the text a QR code carries) into
4
+ * the `nodes[]` JSON this plugin accepts:
5
+ *
6
+ * node scripts/parse-share-link.mjs "vless://uuid@host:443?security=reality&..."
7
+ *
8
+ * Paste the printed array under `nodes:` in cordis.patch.yml, or pass the link
9
+ * directly as `shareLink: "<link>"` and let the plugin parse it at load time.
10
+ */
11
+ import { parseShareLinks } from '../lib/share-link.js'
12
+
13
+ const links = process.argv.slice(2)
14
+ if (links.length === 0) {
15
+ console.error('usage: node scripts/parse-share-link.mjs <vless://...> [more links]')
16
+ process.exit(2)
17
+ }
18
+
19
+ const { nodes, errors, warnings } = parseShareLinks(links)
20
+ for (const warning of warnings) console.error(`warning: ${warning}`)
21
+ for (const error of errors) console.error(`error: ${error.field}: ${error.message}`)
22
+ if (nodes.length > 0) console.log(JSON.stringify(nodes, null, 2))
23
+ if (errors.length > 0) process.exitCode = 1
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Real-runtime routing probe (run it under the DSH runtime, see docs/EVIDENCE.md):
4
+ *
5
+ * ELECTRON_RUN_AS_NODE=1 "E:\DS\DeepSeek Harness.exe" scripts/probe-routing.mjs
6
+ *
7
+ * It starts a fake local HTTP proxy, installs the plugin's route manager
8
+ * against it, and asserts:
9
+ * 1. globalThis.fetch is proxied (absolute-form request line reaches the proxy,
10
+ * and a name that cannot resolve directly still gets a response);
11
+ * 2. loopback traffic bypasses the proxy (origin-form request line);
12
+ * 3. restore hands the dispatcher back (the same direct fetch then fails to
13
+ * resolve, i.e. it is no longer tunnelled).
14
+ *
15
+ * Exits non-zero on the first failed assertion.
16
+ */
17
+ import http from 'node:http'
18
+ import { RouteManager } from '../lib/route.js'
19
+
20
+ const logger = { info() {}, warn() {}, error() {}, debug() {} }
21
+ const failures = []
22
+ const check = (label, condition) => {
23
+ console.log(`${condition ? 'PASS' : 'FAIL'} ${label}`)
24
+ if (!condition) failures.push(label)
25
+ }
26
+
27
+ const seen = []
28
+ const server = http.createServer((req, res) => {
29
+ seen.push(req.url)
30
+ res.writeHead(200, { 'content-type': 'text/plain' })
31
+ res.end('via-local-proxy')
32
+ })
33
+ const port = await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)))
34
+ console.log(`fake proxy on 127.0.0.1:${port}`)
35
+
36
+ const route = new RouteManager({ logger })
37
+ const snapshot = await route.install({ proxyUrl: `http://127.0.0.1:${port}` })
38
+ check('route installed through the harness policy', snapshot.installed && snapshot.mode === 'harness')
39
+
40
+ try {
41
+ const proxied = await fetch('http://probe.invalid/x')
42
+ const body = await proxied.text()
43
+ check('proxied fetch got the proxy answer', proxied.status === 200 && body === 'via-local-proxy')
44
+ check('proxy saw an absolute-form request line', seen.at(-1) === 'http://probe.invalid/x')
45
+
46
+ await fetch(`http://127.0.0.1:${port}/direct`)
47
+ check('loopback traffic bypassed the proxy', seen.at(-1) === '/direct')
48
+ } catch (error) {
49
+ check(`proxied fetch threw: ${error.message}`, false)
50
+ }
51
+
52
+ await route.restore()
53
+ try {
54
+ await fetch('http://probe.invalid/y')
55
+ check('post-restore fetch is direct again', false)
56
+ } catch (error) {
57
+ check('post-restore fetch is direct again (ENOTFOUND)', error.cause?.code === 'ENOTFOUND')
58
+ }
59
+ server.close()
60
+
61
+ if (failures.length > 0) {
62
+ console.error(`\n${failures.length} check(s) failed`)
63
+ process.exitCode = 1
64
+ } else {
65
+ console.log('\nall routing checks passed')
66
+ }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Release self-check: identity, allow-list, secrets, packaging and doc gates.
4
+ *
5
+ * node scripts/release-check.mjs
6
+ *
7
+ * Exit 0 = all checks pass. Every check is printed as JSON so the release
8
+ * evidence can quote it verbatim.
9
+ */
10
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
11
+ import { join, relative } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const ROOT = fileURLToPath(new URL('../', import.meta.url))
15
+ const checks = []
16
+ const check = (name, ok, detail = '') => checks.push({ name, ok, detail })
17
+
18
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
19
+
20
+ check('name is @twinsearth/dsh-2xray', pkg.name === '@twinsearth/dsh-2xray', pkg.name)
21
+ check('version is semver', /^\d+\.\d+\.\d+$/.test(pkg.version), pkg.version)
22
+ check('publishable', pkg.private !== true, `private=${pkg.private}`)
23
+ check('license MIT', pkg.license === 'MIT', String(pkg.license))
24
+ check('type module', pkg.type === 'module', String(pkg.type))
25
+ check('bundle patch declared', pkg.dsh?.bundle?.patch === './cordis.patch.yml', String(pkg.dsh?.bundle?.patch))
26
+
27
+ const patch = readFileSync(join(ROOT, 'cordis.patch.yml'), 'utf8')
28
+ check('patch row name matches package', patch.includes(pkg.name), 'cordis.patch.yml')
29
+ check('patch has an id', /^\s*-\s*id:\s*\S+/m.test(patch), 'cordis.patch.yml')
30
+
31
+ const readme = readFileSync(join(ROOT, 'README.md'), 'utf8')
32
+ check('README title is DSH-2Xray', readme.startsWith('# DSH-2Xray'), readme.split('\n')[0])
33
+ const changelog = readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8')
34
+ check('CHANGELOG has this version', changelog.includes(`## ${pkg.version}`), pkg.version)
35
+
36
+ // ---- allow-list ----------------------------------------------------------
37
+ const allow = readFileSync(join(ROOT, 'release', 'allow-list.txt'), 'utf8')
38
+ .split('\n')
39
+ .map((line) => line.trim())
40
+ .filter((line) => line && !line.startsWith('#'))
41
+
42
+ function globToRegExp(glob) {
43
+ let out = '^'
44
+ for (let index = 0; index < glob.length; index++) {
45
+ const char = glob[index]
46
+ if (char === '*') {
47
+ if (glob[index + 1] === '*') {
48
+ out += '.*'
49
+ index++
50
+ } else out += '[^/]*'
51
+ } else if ('\\^$+.()|{}[]'.includes(char)) out += `\\${char}`
52
+ else out += char
53
+ }
54
+ return new RegExp(`${out}$`)
55
+ }
56
+ const allowPatterns = allow.map(globToRegExp)
57
+ const isAllowed = (rel) => allowPatterns.some((pattern) => pattern.test(rel))
58
+
59
+ const IGNORED = [/^node_modules\//, /^\.git\//, /^\.tmp\//, /^dist\//, /^release\//, /^\.github\//, /^\.gitignore$/]
60
+
61
+ function walk(dir, out = []) {
62
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
63
+ const absolute = join(dir, entry.name)
64
+ const rel = relative(ROOT, absolute).split('\\').join('/')
65
+ if (IGNORED.some((pattern) => pattern.test(rel))) continue
66
+ if (entry.isDirectory()) walk(absolute, out)
67
+ else if (entry.isFile()) out.push(rel)
68
+ }
69
+ return out
70
+ }
71
+
72
+ const files = walk(ROOT)
73
+ const outside = files.filter((file) => !isAllowed(file))
74
+ check('every packaged file is allow-listed', outside.length === 0, outside.join(', ') || `${files.length} files`)
75
+
76
+ for (const required of ['index.js', 'package.json', 'cordis.patch.yml', 'README.md', 'CHANGELOG.md', 'LICENSE', 'lib/supervisor.js', 'lib/control/grpc.js', 'locale/en.json', 'locale/zh.json', 'icon.svg']) {
77
+ check(`required file ${required}`, files.includes(required))
78
+ }
79
+
80
+ // ---- secrets -------------------------------------------------------------
81
+ const SECRET_PATTERNS = [
82
+ [/npm_[A-Za-z0-9]{36,}/, 'npm token'],
83
+ [/github_pat_[A-Za-z0-9_]{20,}/, 'GitHub PAT'],
84
+ [/ghp_[A-Za-z0-9]{30,}/, 'GitHub token'],
85
+ [/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, 'private key'],
86
+ ]
87
+ const leaks = []
88
+ for (const file of files) {
89
+ if (!/\.(js|mjs|json|md|yml|yaml|txt)$/.test(file)) continue
90
+ const absolute = join(ROOT, file)
91
+ if (statSync(absolute).size > 1024 * 1024) continue
92
+ const text = readFileSync(absolute, 'utf8')
93
+ for (const [pattern, label] of SECRET_PATTERNS) {
94
+ if (pattern.test(text)) leaks.push(`${file}: ${label}`)
95
+ }
96
+ }
97
+ check('no secrets in packaged files', leaks.length === 0, leaks.join(', ') || 'clean')
98
+
99
+ const binFiles = files.filter((file) => file.startsWith('bin/'))
100
+ check('no xray binary committed', binFiles.every((file) => file.endsWith('README.md')), binFiles.join(', ') || 'none')
101
+
102
+ const failures = checks.filter((entry) => !entry.ok)
103
+ console.log(JSON.stringify({ ok: failures.length === 0, package: `${pkg.name}@${pkg.version}`, files: files.length, checks }, null, 2))
104
+ if (failures.length > 0) process.exitCode = 1