@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/bin/README.md +27 -0
- package/cordis.patch.yml +37 -0
- package/docs/AC-0.md +60 -0
- package/docs/EVIDENCE.md +135 -0
- package/examples/config.downgrade.json +20 -0
- package/examples/config.example.json +22 -0
- package/examples/config.multi.downgrade.json +39 -0
- package/examples/config.multi.json +46 -0
- package/icon.svg +9 -0
- package/index.js +89 -0
- package/lib/binary.js +133 -0
- package/lib/config.js +401 -0
- package/lib/control/client.js +100 -0
- package/lib/control/grpc.js +121 -0
- package/lib/control/protobuf.js +143 -0
- package/lib/health.js +109 -0
- package/lib/log.js +20 -0
- package/lib/paths.js +65 -0
- package/lib/process.js +197 -0
- package/lib/route.js +299 -0
- package/lib/share-link.js +132 -0
- package/lib/supervisor.js +687 -0
- package/lib/tools.js +106 -0
- package/lib/xray-config.js +225 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +42 -4
- package/scripts/check-config.mjs +56 -0
- package/scripts/parse-share-link.mjs +23 -0
- package/scripts/probe-routing.mjs +66 -0
- package/scripts/release-check.mjs +104 -0
- package/test/binary.test.js +103 -0
- package/test/config.test.js +106 -0
- package/test/control.test.js +166 -0
- package/test/health.test.js +75 -0
- package/test/index.test.js +52 -0
- package/test/multi-node.test.js +111 -0
- package/test/process.test.js +49 -0
- package/test/route.test.js +158 -0
- package/test/share-link.test.js +113 -0
- package/test/supervisor.test.js +525 -0
- package/test/tools.test.js +88 -0
- 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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|