blue-js-sdk 2.0.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 (215) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/ai-path/ADMIN-ELEVATION.md +116 -0
  5. package/ai-path/AI-MANIFESTO.md +185 -0
  6. package/ai-path/BREAKING.md +74 -0
  7. package/ai-path/CHECKLIST.md +619 -0
  8. package/ai-path/CONNECTION-STEPS.md +724 -0
  9. package/ai-path/DECISION-TREE.md +378 -0
  10. package/ai-path/DEPENDENCIES.md +459 -0
  11. package/ai-path/E2E-FLOW.md +1555 -0
  12. package/ai-path/FAILURES.md +403 -0
  13. package/ai-path/GUIDE.md +1217 -0
  14. package/ai-path/README.md +558 -0
  15. package/ai-path/SPLIT-TUNNEL.md +266 -0
  16. package/ai-path/cli.js +535 -0
  17. package/ai-path/connect.js +884 -0
  18. package/ai-path/discover.js +178 -0
  19. package/ai-path/environment.js +266 -0
  20. package/ai-path/errors.js +86 -0
  21. package/ai-path/examples/autonomous-agent.mjs +220 -0
  22. package/ai-path/examples/multi-region.mjs +174 -0
  23. package/ai-path/examples/one-shot.mjs +31 -0
  24. package/ai-path/index.js +60 -0
  25. package/ai-path/pricing.js +136 -0
  26. package/ai-path/recommend.js +413 -0
  27. package/ai-path/run-admin.vbs +25 -0
  28. package/ai-path/setup.js +291 -0
  29. package/ai-path/wallet.js +137 -0
  30. package/app-helpers.js +363 -0
  31. package/app-settings.js +95 -0
  32. package/app-types.js +267 -0
  33. package/audit.js +847 -0
  34. package/batch.js +293 -0
  35. package/bin/setup.js +376 -0
  36. package/chain/authz.js +109 -0
  37. package/chain/broadcast.js +472 -0
  38. package/chain/client.js +160 -0
  39. package/chain/fee-grants.js +305 -0
  40. package/chain/index.js +891 -0
  41. package/chain/lcd.js +313 -0
  42. package/chain/queries.js +547 -0
  43. package/chain/rpc.js +408 -0
  44. package/chain/wallet.js +141 -0
  45. package/cli/config.js +143 -0
  46. package/cli/index.js +463 -0
  47. package/cli/output.js +182 -0
  48. package/cli.js +491 -0
  49. package/client/index.js +251 -0
  50. package/client.js +271 -0
  51. package/config/index.js +255 -0
  52. package/connection/connect.js +849 -0
  53. package/connection/disconnect.js +180 -0
  54. package/connection/discovery.js +321 -0
  55. package/connection/index.js +76 -0
  56. package/connection/proxy.js +148 -0
  57. package/connection/resilience.js +428 -0
  58. package/connection/security.js +232 -0
  59. package/connection/state.js +369 -0
  60. package/connection/tunnel.js +691 -0
  61. package/consumer.js +132 -0
  62. package/cosmjs-setup.js +1884 -0
  63. package/defaults.js +366 -0
  64. package/disk-cache.js +107 -0
  65. package/dist/client.d.ts +108 -0
  66. package/dist/client.d.ts.map +1 -0
  67. package/dist/client.js +400 -0
  68. package/dist/client.js.map +1 -0
  69. package/dist/index.d.ts +8 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +8 -0
  72. package/dist/index.js.map +1 -0
  73. package/errors/index.js +112 -0
  74. package/errors.js +218 -0
  75. package/examples/README.md +64 -0
  76. package/examples/connect-direct.mjs +106 -0
  77. package/examples/connect-plan.mjs +125 -0
  78. package/examples/error-handling.mjs +109 -0
  79. package/examples/query-nodes.mjs +94 -0
  80. package/examples/wallet-basics.mjs +61 -0
  81. package/generated/amino/amino.ts +9 -0
  82. package/generated/cosmos/base/v1beta1/coin.ts +365 -0
  83. package/generated/cosmos_proto/cosmos.ts +323 -0
  84. package/generated/gogoproto/gogo.ts +9 -0
  85. package/generated/google/protobuf/descriptor.ts +7601 -0
  86. package/generated/google/protobuf/duration.ts +208 -0
  87. package/generated/google/protobuf/timestamp.ts +238 -0
  88. package/generated/sentinel/lease/v1/events.ts +924 -0
  89. package/generated/sentinel/lease/v1/lease.ts +292 -0
  90. package/generated/sentinel/lease/v1/msg.ts +949 -0
  91. package/generated/sentinel/lease/v1/params.ts +164 -0
  92. package/generated/sentinel/node/v3/events.ts +881 -0
  93. package/generated/sentinel/node/v3/msg.ts +1002 -0
  94. package/generated/sentinel/node/v3/node.ts +263 -0
  95. package/generated/sentinel/node/v3/params.ts +183 -0
  96. package/generated/sentinel/plan/v3/events.ts +675 -0
  97. package/generated/sentinel/plan/v3/msg.ts +1191 -0
  98. package/generated/sentinel/plan/v3/plan.ts +283 -0
  99. package/generated/sentinel/provider/v2/events.ts +171 -0
  100. package/generated/sentinel/provider/v2/msg.ts +480 -0
  101. package/generated/sentinel/provider/v2/params.ts +131 -0
  102. package/generated/sentinel/provider/v2/provider.ts +246 -0
  103. package/generated/sentinel/session/v3/events.ts +480 -0
  104. package/generated/sentinel/session/v3/msg.ts +616 -0
  105. package/generated/sentinel/session/v3/params.ts +260 -0
  106. package/generated/sentinel/session/v3/proof.ts +180 -0
  107. package/generated/sentinel/session/v3/session.ts +384 -0
  108. package/generated/sentinel/subscription/v3/events.ts +1181 -0
  109. package/generated/sentinel/subscription/v3/msg.ts +1305 -0
  110. package/generated/sentinel/subscription/v3/params.ts +167 -0
  111. package/generated/sentinel/subscription/v3/subscription.ts +315 -0
  112. package/generated/sentinel/types/v1/bandwidth.ts +124 -0
  113. package/generated/sentinel/types/v1/price.ts +149 -0
  114. package/generated/sentinel/types/v1/renewal.ts +87 -0
  115. package/generated/sentinel/types/v1/status.ts +54 -0
  116. package/generated/typeRegistry.ts +27 -0
  117. package/index.js +486 -0
  118. package/node-connect.js +3015 -0
  119. package/operator.js +134 -0
  120. package/package.json +113 -0
  121. package/plan-operations.js +199 -0
  122. package/preflight.js +352 -0
  123. package/pricing/index.js +262 -0
  124. package/proto/amino/amino.proto +84 -0
  125. package/proto/cosmos/base/v1beta1/coin.proto +61 -0
  126. package/proto/cosmos_proto/cosmos.proto +112 -0
  127. package/proto/gogoproto/gogo.proto +145 -0
  128. package/proto/google/api/annotations.proto +31 -0
  129. package/proto/google/api/http.proto +370 -0
  130. package/proto/google/protobuf/any.proto +106 -0
  131. package/proto/google/protobuf/duration.proto +115 -0
  132. package/proto/google/protobuf/timestamp.proto +145 -0
  133. package/proto/sentinel/lease/v1/events.proto +52 -0
  134. package/proto/sentinel/lease/v1/genesis.proto +15 -0
  135. package/proto/sentinel/lease/v1/lease.proto +25 -0
  136. package/proto/sentinel/lease/v1/msg.proto +62 -0
  137. package/proto/sentinel/lease/v1/params.proto +17 -0
  138. package/proto/sentinel/node/v3/events.proto +50 -0
  139. package/proto/sentinel/node/v3/genesis.proto +15 -0
  140. package/proto/sentinel/node/v3/msg.proto +63 -0
  141. package/proto/sentinel/node/v3/node.proto +27 -0
  142. package/proto/sentinel/node/v3/params.proto +21 -0
  143. package/proto/sentinel/node/v3/querier.proto +63 -0
  144. package/proto/sentinel/plan/v3/events.proto +41 -0
  145. package/proto/sentinel/plan/v3/genesis.proto +21 -0
  146. package/proto/sentinel/plan/v3/msg.proto +83 -0
  147. package/proto/sentinel/plan/v3/plan.proto +32 -0
  148. package/proto/sentinel/plan/v3/querier.proto +53 -0
  149. package/proto/sentinel/provider/v2/events.proto +16 -0
  150. package/proto/sentinel/provider/v2/genesis.proto +15 -0
  151. package/proto/sentinel/provider/v2/msg.proto +35 -0
  152. package/proto/sentinel/provider/v2/params.proto +17 -0
  153. package/proto/sentinel/provider/v2/provider.proto +24 -0
  154. package/proto/sentinel/provider/v3/genesis.proto +15 -0
  155. package/proto/sentinel/provider/v3/params.proto +13 -0
  156. package/proto/sentinel/session/v3/events.proto +30 -0
  157. package/proto/sentinel/session/v3/genesis.proto +15 -0
  158. package/proto/sentinel/session/v3/msg.proto +50 -0
  159. package/proto/sentinel/session/v3/params.proto +25 -0
  160. package/proto/sentinel/session/v3/proof.proto +25 -0
  161. package/proto/sentinel/session/v3/querier.proto +100 -0
  162. package/proto/sentinel/session/v3/session.proto +50 -0
  163. package/proto/sentinel/subscription/v2/allocation.proto +21 -0
  164. package/proto/sentinel/subscription/v2/payout.proto +22 -0
  165. package/proto/sentinel/subscription/v3/events.proto +65 -0
  166. package/proto/sentinel/subscription/v3/genesis.proto +17 -0
  167. package/proto/sentinel/subscription/v3/msg.proto +83 -0
  168. package/proto/sentinel/subscription/v3/params.proto +21 -0
  169. package/proto/sentinel/subscription/v3/subscription.proto +33 -0
  170. package/proto/sentinel/types/v1/bandwidth.proto +19 -0
  171. package/proto/sentinel/types/v1/price.proto +21 -0
  172. package/proto/sentinel/types/v1/renewal.proto +21 -0
  173. package/proto/sentinel/types/v1/status.proto +16 -0
  174. package/protocol/encoding.js +341 -0
  175. package/protocol/events.js +361 -0
  176. package/protocol/handshake.js +297 -0
  177. package/protocol/index.js +15 -0
  178. package/protocol/messages.js +346 -0
  179. package/protocol/plans.js +199 -0
  180. package/protocol/v2ray.js +268 -0
  181. package/protocol/v3.js +723 -0
  182. package/protocol/wireguard.js +125 -0
  183. package/security/index.js +132 -0
  184. package/session-manager.js +329 -0
  185. package/session-tracker.js +80 -0
  186. package/setup.js +376 -0
  187. package/speedtest/index.js +528 -0
  188. package/speedtest.js +567 -0
  189. package/src/client.ts +502 -0
  190. package/src/index.ts +20 -0
  191. package/state/index.js +347 -0
  192. package/state.js +516 -0
  193. package/test-all-chain-ops.js +493 -0
  194. package/test-all-logic.js +199 -0
  195. package/test-all-msg-types.js +292 -0
  196. package/test-every-connection.js +208 -0
  197. package/test-feegrant-connect.js +98 -0
  198. package/test-logic.js +148 -0
  199. package/test-mainnet.js +176 -0
  200. package/test-plan-lifecycle.js +335 -0
  201. package/tls-trust.js +132 -0
  202. package/tsconfig.build.json +20 -0
  203. package/tsconfig.json +34 -0
  204. package/types/chain.d.ts +746 -0
  205. package/types/connection.d.ts +425 -0
  206. package/types/errors.d.ts +174 -0
  207. package/types/index.d.ts +1380 -0
  208. package/types/nodes.d.ts +187 -0
  209. package/types/pricing.d.ts +156 -0
  210. package/types/protocol.d.ts +332 -0
  211. package/types/session.d.ts +236 -0
  212. package/types/settings.d.ts +192 -0
  213. package/v3protocol.js +1053 -0
  214. package/wallet/index.js +153 -0
  215. package/wireguard.js +307 -0
@@ -0,0 +1,691 @@
1
+ /**
2
+ * Tunnel Management — WireGuard and V2Ray tunnel setup, verification, and helpers.
3
+ *
4
+ * Handles the low-level process of creating and verifying VPN tunnels
5
+ * after a successful handshake.
6
+ */
7
+
8
+ import axios from 'axios';
9
+ import { execSync, execFileSync, spawn } from 'child_process';
10
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
11
+ import path from 'path';
12
+ import os from 'os';
13
+
14
+ import {
15
+ events, _defaultState, _activeStates, progress, checkAborted,
16
+ _endSessionOnChain,
17
+ } from './state.js';
18
+ import { enableKillSwitch, isKillSwitchEnabled, disableKillSwitch } from './security.js';
19
+ import { setSystemProxy } from './proxy.js';
20
+
21
+ import {
22
+ generateWgKeyPair, initHandshakeV3,
23
+ writeWgConfig, generateV2RayUUID, initHandshakeV3V2Ray,
24
+ buildV2RayClientConfig, waitForPort,
25
+ } from '../v3protocol.js';
26
+ import { installWgTunnel, disconnectWireGuard, WG_AVAILABLE, IS_ADMIN } from '../wireguard.js';
27
+ import { resolveSpeedtestIPs } from '../speedtest.js';
28
+ import {
29
+ saveState, clearState, saveCredentials, clearCredentials,
30
+ } from '../state.js';
31
+ import {
32
+ DEFAULT_TIMEOUTS, sleep, recordTransportResult, resolveDnsServers,
33
+ } from '../defaults.js';
34
+ import {
35
+ NodeError, TunnelError, ErrorCodes,
36
+ } from '../errors.js';
37
+ import { createNodeHttpsAgent } from '../tls-trust.js';
38
+
39
+ // ─── V2Ray binary detection ──────────────────────────────────────────────────
40
+ // Search common locations so apps can find an existing v2ray.exe instead of
41
+ // requiring every project to bundle its own copy.
42
+
43
+ export function findV2RayExe(hint) {
44
+ const binary = process.platform === 'win32' ? 'v2ray.exe' : 'v2ray';
45
+
46
+ // 1. Explicit path (if provided and exists)
47
+ if (hint && existsSync(hint)) return hint;
48
+
49
+ // 2. Environment variable
50
+ if (process.env.V2RAY_PATH && existsSync(process.env.V2RAY_PATH)) {
51
+ return process.env.V2RAY_PATH;
52
+ }
53
+
54
+ // 3. Search common locations (cross-platform)
55
+ const home = os.homedir();
56
+ const searchPaths = [
57
+ // ─── Relative to CWD (works for any project layout) ───
58
+ path.join(process.cwd(), 'bin', binary),
59
+ path.join(process.cwd(), 'resources', 'bin', binary),
60
+
61
+ // ─── Relative to SDK code dir (npm install or git clone) ───
62
+ path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), '..', 'bin', binary),
63
+
64
+ // ─── Electron / bundled app paths ───
65
+ // process.resourcesPath is set by Electron for packaged apps
66
+ ...(typeof process.resourcesPath === 'string' ? [
67
+ path.join(process.resourcesPath, 'bin', binary),
68
+ path.join(process.resourcesPath, 'app.asar.unpacked', 'bin', binary),
69
+ path.join(process.resourcesPath, 'extraResources', binary),
70
+ ] : []),
71
+
72
+ // ─── Windows ───
73
+ ...(process.platform === 'win32' ? [
74
+ 'C:\\Program Files\\V2Ray\\v2ray.exe',
75
+ 'C:\\Program Files (x86)\\V2Ray\\v2ray.exe',
76
+ path.join(home, 'AppData', 'Local', 'v2ray', 'v2ray.exe'),
77
+ path.join(home, 'AppData', 'Local', 'Programs', 'v2ray', 'v2ray.exe'),
78
+ path.join(home, 'scoop', 'apps', 'v2ray', 'current', 'v2ray.exe'),
79
+ ] : []),
80
+
81
+ // ─── macOS ───
82
+ ...(process.platform === 'darwin' ? [
83
+ '/usr/local/bin/v2ray',
84
+ '/opt/homebrew/bin/v2ray',
85
+ path.join(home, 'Library', 'Application Support', 'v2ray', 'v2ray'),
86
+ '/Applications/V2Ray.app/Contents/MacOS/v2ray',
87
+ ] : []),
88
+
89
+ // ─── Linux ───
90
+ ...(process.platform === 'linux' ? [
91
+ '/usr/local/bin/v2ray',
92
+ '/usr/bin/v2ray',
93
+ path.join(home, '.local', 'bin', 'v2ray'),
94
+ '/snap/v2ray/current/bin/v2ray',
95
+ path.join(home, '.config', 'v2ray', 'v2ray'),
96
+ ] : []),
97
+ ];
98
+
99
+ for (const p of searchPaths) {
100
+ try { if (existsSync(p)) return p; } catch {} // catch invalid paths on non-matching platforms
101
+ }
102
+
103
+ // 4. Check system PATH
104
+ try {
105
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
106
+ const arg = process.platform === 'win32' ? 'v2ray.exe' : 'v2ray';
107
+ const result = execFileSync(cmd, [arg], { encoding: 'utf8', stdio: 'pipe' }).trim();
108
+ if (result) return result.split('\n')[0].trim();
109
+ } catch {}
110
+
111
+ return null;
112
+ }
113
+
114
+ // ─── Pre-validation (MUST run before paying for session) ─────────────────────
115
+
116
+ /**
117
+ * Validate that tunnel requirements are met BEFORE paying for a session.
118
+ * Prevents burning P2P on sessions that can never produce a working tunnel.
119
+ *
120
+ * For V2Ray: searches system for an existing v2ray.exe if the provided path
121
+ * doesn't exist. Returns the resolved path so callers can use it.
122
+ *
123
+ * Throws with a clear error message if requirements are not met.
124
+ */
125
+ export function validateTunnelRequirements(serviceType, v2rayExePath) {
126
+ if (serviceType === 'v2ray') {
127
+ const resolved = findV2RayExe(v2rayExePath);
128
+ if (!resolved) {
129
+ const searched = v2rayExePath ? `Checked: ${v2rayExePath} (not found). ` : '';
130
+ throw new TunnelError(ErrorCodes.V2RAY_NOT_FOUND, `${searched}V2Ray binary not found anywhere on this system. Either: (a) set v2rayExePath to the correct path, (b) set V2RAY_PATH env var, (c) add v2ray.exe to PATH, or (d) download v2ray-core v5.x from https://github.com/v2fly/v2ray-core/releases and place v2ray.exe + geoip.dat + geosite.dat in a bin/ directory.`, { checked: v2rayExePath });
131
+ }
132
+ if (resolved !== v2rayExePath) {
133
+ console.log(`V2Ray binary found at: ${resolved} (auto-detected)`);
134
+ }
135
+ // V2Ray version check — 5.44.1+ has observatory bugs that break multi-outbound configs
136
+ try {
137
+ const verOut = execFileSync(resolved, ['version'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
138
+ const verMatch = verOut.match(/V2Ray\s+(\d+\.\d+\.\d+)/i) || verOut.match(/(\d+\.\d+\.\d+)/);
139
+ if (verMatch) {
140
+ const [major, minor] = verMatch[1].split('.').map(Number);
141
+ if (major >= 5 && minor >= 44) {
142
+ console.warn(`[sentinel-sdk] WARNING: V2Ray ${verMatch[1]} detected — v5.44.1+ has observatory bugs. Recommended: v5.2.1 exactly.`);
143
+ }
144
+ }
145
+ } catch { /* version check is best-effort */ }
146
+ return resolved;
147
+ } else if (serviceType === 'wireguard') {
148
+ if (!WG_AVAILABLE) {
149
+ throw new TunnelError(ErrorCodes.WG_NOT_AVAILABLE, 'WireGuard node selected but WireGuard is not installed. Download from https://download.wireguard.com/windows-client/wireguard-installer.exe');
150
+ }
151
+ if (process.platform === 'win32' && !IS_ADMIN) {
152
+ throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'WireGuard requires administrator privileges. Restart your application as Administrator.');
153
+ }
154
+ }
155
+ return v2rayExePath;
156
+ }
157
+
158
+ /**
159
+ * Pre-flight check: verify all required binaries and permissions.
160
+ *
161
+ * Call this at app startup to surface clear, human-readable errors
162
+ * instead of cryptic ENOENT crashes mid-connection.
163
+ *
164
+ * @param {object} [opts]
165
+ * @param {string} [opts.v2rayExePath] - Explicit V2Ray binary path
166
+ * @returns {{ ok, v2ray, wireguard, platform, arch, nodeVersion, errors }}
167
+ */
168
+ export function verifyDependencies(opts = {}) {
169
+ const errors = [];
170
+ const result = {
171
+ ok: true,
172
+ v2ray: { available: false, path: null, version: null, error: null },
173
+ wireguard: { available: false, path: null, isAdmin: IS_ADMIN, error: null },
174
+ platform: process.platform,
175
+ arch: process.arch,
176
+ nodeVersion: process.version,
177
+ errors,
178
+ };
179
+
180
+ // V2Ray check
181
+ const v2path = findV2RayExe(opts.v2rayExePath);
182
+ if (v2path) {
183
+ result.v2ray.available = true;
184
+ result.v2ray.path = v2path;
185
+ try {
186
+ const ver = execFileSync(v2path, ['version'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
187
+ const match = ver.match(/V2Ray\s+([\d.]+)/i) || ver.match(/([\d]+\.[\d]+\.[\d]+)/);
188
+ result.v2ray.version = match ? match[1] : ver.trim().split('\n')[0];
189
+ } catch {
190
+ result.v2ray.version = 'unknown (binary exists but version check failed)';
191
+ }
192
+ } else {
193
+ result.v2ray.error = process.platform === 'win32'
194
+ ? 'V2Ray not found. Place v2ray.exe + geoip.dat + geosite.dat in a bin/ folder next to your app, or set the V2RAY_PATH environment variable.'
195
+ : process.platform === 'darwin'
196
+ ? 'V2Ray not found. Install via: brew install v2ray, or place the v2ray binary in ./bin/ or /usr/local/bin/'
197
+ : 'V2Ray not found. Install via your package manager (apt install v2ray), or place the v2ray binary in ./bin/ or /usr/local/bin/';
198
+ errors.push(result.v2ray.error);
199
+ }
200
+
201
+ // WireGuard check
202
+ if (WG_AVAILABLE) {
203
+ result.wireguard.available = true;
204
+ result.wireguard.path = process.platform === 'win32'
205
+ ? ['C:\\Program Files\\WireGuard\\wireguard.exe', 'C:\\Program Files (x86)\\WireGuard\\wireguard.exe'].find(p => existsSync(p)) || 'in PATH'
206
+ : (() => { try { return execSync('which wg-quick', { encoding: 'utf8', stdio: 'pipe' }).trim(); } catch { return 'in PATH'; } })();
207
+ if (!IS_ADMIN) {
208
+ result.wireguard.error = process.platform === 'win32'
209
+ ? 'WireGuard requires Administrator privileges. Run your app as Admin, or use V2Ray nodes (no admin needed).'
210
+ : 'WireGuard requires root/sudo. Run with sudo, or use V2Ray nodes (no root needed).';
211
+ errors.push(result.wireguard.error);
212
+ }
213
+ } else {
214
+ result.wireguard.error = process.platform === 'win32'
215
+ ? 'WireGuard not installed. Download from https://download.wireguard.com/windows-client/wireguard-installer.exe — V2Ray nodes still work without it.'
216
+ : process.platform === 'darwin'
217
+ ? 'WireGuard not installed. Install via: brew install wireguard-tools — V2Ray nodes still work without it.'
218
+ : 'WireGuard not installed. Install via: sudo apt install wireguard (or equivalent) — V2Ray nodes still work without it.';
219
+ errors.push(result.wireguard.error);
220
+ }
221
+
222
+ result.ok = errors.length === 0;
223
+ return result;
224
+ }
225
+
226
+ // ─── Handshake & Tunnel Setup ────────────────────────────────────────────────
227
+
228
+ export async function performHandshake({ serviceType, remoteUrl, serverHost, sessionId, privKey, v2rayExePath, fullTunnel, splitIPs, systemProxy, killSwitch, dns, onProgress, logFn, extremeDrift, clockDriftSec, nodeAddress, timeouts, signal, nodeAgent, state }) {
229
+ if (serviceType === 'wireguard') {
230
+ return await setupWireGuard({ remoteUrl, sessionId, privKey, fullTunnel, splitIPs, killSwitch, dns, onProgress, logFn, nodeAddress, timeouts, signal, nodeAgent, state });
231
+ } else {
232
+ return await setupV2Ray({ remoteUrl, serverHost, sessionId, privKey, v2rayExePath, systemProxy, dns, onProgress, logFn, extremeDrift, clockDriftSec, nodeAddress, timeouts, signal, nodeAgent, state });
233
+ }
234
+ }
235
+
236
+ async function setupWireGuard({ remoteUrl, sessionId, privKey, fullTunnel, splitIPs, killSwitch, dns, onProgress, logFn, nodeAddress, timeouts, signal, nodeAgent, state }) {
237
+ // Generate WireGuard keys
238
+ const wgKeys = generateWgKeyPair();
239
+
240
+ // Handshake with node
241
+ checkAborted(signal);
242
+ progress(onProgress, logFn, 'handshake', 'WireGuard handshake...');
243
+ const hs = await initHandshakeV3(remoteUrl, sessionId, privKey, wgKeys.publicKey, nodeAgent);
244
+
245
+ // NOTE: Credentials are saved AFTER verified connectivity (not here).
246
+ // Saving before verification causes stale credentials to persist on retry
247
+ // when the tunnel fails — the node doesn't route traffic with old UUID/keys.
248
+
249
+ // Resolve AllowedIPs based on fullTunnel flag:
250
+ // - fullTunnel=true (default): 0.0.0.0/0 — routes ALL traffic, changes your IP
251
+ // - fullTunnel=false: only speedtest IPs — safe for testing, IP unchanged
252
+ // - splitIPs=[...]: explicit IPs override everything
253
+ let resolvedSplitIPs = null;
254
+ if (splitIPs && Array.isArray(splitIPs) && splitIPs.length > 0) {
255
+ // Explicit split IPs provided — use them as-is
256
+ resolvedSplitIPs = splitIPs;
257
+ progress(onProgress, logFn, 'tunnel', `Split tunnel: routing ${resolvedSplitIPs.length} explicit IPs`);
258
+ } else if (fullTunnel) {
259
+ // Full tunnel: pass null to writeWgConfig → generates 0.0.0.0/0, ::/0
260
+ resolvedSplitIPs = null;
261
+ progress(onProgress, logFn, 'tunnel', 'Full tunnel mode (0.0.0.0/0) — all traffic through VPN');
262
+ } else {
263
+ // Safe split tunnel: only route speedtest IPs
264
+ try {
265
+ resolvedSplitIPs = await resolveSpeedtestIPs();
266
+ progress(onProgress, logFn, 'tunnel', `Split tunnel: routing ${resolvedSplitIPs.length} speedtest IPs`);
267
+ } catch {
268
+ // Can't resolve speedtest IPs, fall back to full tunnel
269
+ resolvedSplitIPs = null;
270
+ progress(onProgress, logFn, 'tunnel', 'Warning: could not resolve speedtest IPs, falling back to full tunnel');
271
+ }
272
+ }
273
+
274
+ // v28: VERIFY-BEFORE-CAPTURE — install with safe split IPs first, verify tunnel works,
275
+ // THEN switch to full tunnel (0.0.0.0/0). This prevents killing the user's internet
276
+ // if the node is broken. Previously, fullTunnel captured ALL traffic before verification,
277
+ // causing up to ~78s of dead internet on failure.
278
+ const VERIFY_IPS = ['1.1.1.1/32', '1.0.0.1/32'];
279
+ const VERIFY_TARGETS = ['https://1.1.1.1', 'https://1.0.0.1'];
280
+ const needsFullTunnelSwitch = fullTunnel && (!resolvedSplitIPs || resolvedSplitIPs.length === 0);
281
+
282
+ const initialSplitIPs = needsFullTunnelSwitch ? VERIFY_IPS : resolvedSplitIPs;
283
+ const confPath = writeWgConfig(
284
+ wgKeys.privateKey,
285
+ hs.assignedAddrs,
286
+ hs.serverPubKey,
287
+ hs.serverEndpoint,
288
+ initialSplitIPs,
289
+ { dns: resolveDnsServers(dns) },
290
+ );
291
+
292
+ // DON'T zero private key yet — may need to rewrite config for full tunnel switch
293
+ // wgKeys.privateKey.fill(0); // deferred to after potential second config write
294
+
295
+ // Wait for node to register peer then install + verify tunnel.
296
+ // v20: Fixed 5s sleep. v21: Exponential retry — try install at 1.5s, then 3s, 5s.
297
+ // Most nodes register the peer within 1-2s. Saves ~3s on average.
298
+ progress(onProgress, logFn, 'tunnel', 'Waiting for node to register peer...');
299
+ const installDelays = [1500, 1500, 2000]; // total budget: 5s (same as before but tries earlier)
300
+ let tunnelInstalled = false;
301
+ for (let i = 0; i < installDelays.length; i++) {
302
+ await sleep(installDelays[i]);
303
+ checkAborted(signal);
304
+ try {
305
+ progress(onProgress, logFn, 'tunnel', `Installing WireGuard tunnel (attempt ${i + 1}/${installDelays.length})...`);
306
+ await installWgTunnel(confPath);
307
+ state.wgTunnel = 'wgsent0';
308
+ tunnelInstalled = true;
309
+ break;
310
+ } catch (installErr) {
311
+ if (i === installDelays.length - 1) {
312
+ wgKeys.privateKey.fill(0);
313
+ throw installErr; // last attempt — propagate
314
+ }
315
+ progress(onProgress, logFn, 'tunnel', `Tunnel install attempt ${i + 1} failed, retrying...`);
316
+ }
317
+ }
318
+
319
+ // Verify actual connectivity through the tunnel.
320
+ // A RUNNING service doesn't guarantee packets flow — the peer might reject us,
321
+ // the endpoint might be firewalled, or the handshake may have been for a stale session.
322
+ // v28: When fullTunnel, we're still on safe split IPs — user's internet is unaffected.
323
+ progress(onProgress, logFn, 'verify', 'Verifying tunnel connectivity...');
324
+ // v29: 1 attempt x 2 targets x 5s = ~10s max exposure. Tear down immediately on failure.
325
+ const verifyTargets = needsFullTunnelSwitch ? VERIFY_TARGETS : null;
326
+ const tunnelWorks = await verifyWgConnectivity(1, verifyTargets);
327
+ if (!tunnelWorks) {
328
+ wgKeys.privateKey.fill(0);
329
+ clearCredentials(nodeAddress); // Clear stale handshake credentials so retry gets fresh ones
330
+ progress(onProgress, logFn, 'verify', 'WireGuard tunnel installed but no traffic flows. Tearing down immediately...');
331
+ try { await disconnectWireGuard(); } catch (e) { logFn?.(`[cleanup] WG disconnect warning: ${e.message}`); }
332
+ state.wgTunnel = null;
333
+ throw new TunnelError(ErrorCodes.WG_NO_CONNECTIVITY, 'WireGuard tunnel installed (service RUNNING) but connectivity check failed — no traffic flows through the tunnel. The node may have rejected the peer or the session may be stale.', { nodeAddress, sessionId: String(sessionId) });
334
+ }
335
+
336
+ // Capture private key base64 BEFORE zeroing — needed for credential save after verification.
337
+ const wgPrivKeyB64 = wgKeys.privateKey.toString('base64');
338
+
339
+ // v28: Tunnel verified! If fullTunnel, switch from safe split IPs to 0.0.0.0/0
340
+ // Don't manually disconnect — installWgTunnel() handles its own force-remove + 1s wait.
341
+ // Double-uninstall races with Windows Service Manager and causes "failed to start" errors.
342
+ if (needsFullTunnelSwitch) {
343
+ progress(onProgress, logFn, 'tunnel', 'Verified! Switching to full tunnel (0.0.0.0/0)...');
344
+ const fullConfPath = writeWgConfig(
345
+ wgKeys.privateKey,
346
+ hs.assignedAddrs,
347
+ hs.serverPubKey,
348
+ hs.serverEndpoint,
349
+ null, // null = 0.0.0.0/0, ::/0
350
+ { dns: resolveDnsServers(dns) },
351
+ );
352
+ wgKeys.privateKey.fill(0); // Zero AFTER final config write
353
+ state.wgTunnel = null;
354
+ await installWgTunnel(fullConfPath);
355
+ state.wgTunnel = 'wgsent0';
356
+ } else {
357
+ wgKeys.privateKey.fill(0); // Zero for non-fullTunnel path
358
+ }
359
+
360
+ progress(onProgress, logFn, 'verify', 'WireGuard connected and verified!');
361
+
362
+ // Save credentials AFTER verified connectivity — prevents stale credentials
363
+ // from persisting when handshake succeeds but tunnel fails to route traffic.
364
+ saveCredentials(nodeAddress, String(sessionId), {
365
+ serviceType: 'wireguard',
366
+ wgPrivateKey: wgPrivKeyB64,
367
+ wgServerPubKey: hs.serverPubKey,
368
+ wgAssignedAddrs: hs.assignedAddrs,
369
+ wgServerEndpoint: hs.serverEndpoint,
370
+ });
371
+
372
+ // Enable kill switch if opts.killSwitch is true
373
+ if (killSwitch) {
374
+ try {
375
+ enableKillSwitch(hs.serverEndpoint);
376
+ logFn?.('[kill-switch] Enabled — all non-tunnel traffic blocked');
377
+ } catch (e) { logFn?.(`[kill-switch] Warning: ${e.message}`); }
378
+ }
379
+
380
+ saveState({ sessionId: String(sessionId), serviceType: 'wireguard', wgTunnelName: 'wgsent0', confPath, systemProxySet: false });
381
+ const sessionIdStr = String(sessionId); // String, not BigInt — safe for JSON.stringify
382
+ state.connection = { sessionId: sessionIdStr, serviceType: 'wireguard', nodeAddress, connectedAt: Date.now() };
383
+ return {
384
+ sessionId: sessionIdStr,
385
+ serviceType: 'wireguard',
386
+ nodeAddress,
387
+ confPath,
388
+ cleanup: async () => {
389
+ if (isKillSwitchEnabled()) disableKillSwitch();
390
+ try { await disconnectWireGuard(); } catch {} // tunnel may already be down
391
+ // End session on chain (fire-and-forget)
392
+ if (sessionIdStr && state._mnemonic) {
393
+ _endSessionOnChain(sessionIdStr, state._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
394
+ }
395
+ state.wgTunnel = null;
396
+ state.connection = null;
397
+ state._mnemonic = null;
398
+ clearState();
399
+ },
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Verify that traffic actually flows through the WireGuard tunnel.
405
+ * Tries HEAD requests to reliable targets. For full tunnel (0.0.0.0/0) all
406
+ * traffic goes through it. For split tunnel, the speedtest IPs are routed.
407
+ */
408
+ export async function verifyWgConnectivity(maxAttempts = 1, customTargets = null) {
409
+ // v29: Reduced from 3 attempts x 3 targets x 8s to 1 attempt x 2 targets x 5s.
410
+ // Old config: worst case ~78s of dead internet if node is broken with fullTunnel.
411
+ // New config: worst case ~10s exposure. Tunnel is torn down immediately on failure.
412
+ const targets = customTargets || ['https://1.1.1.1', 'https://www.cloudflare.com'];
413
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
414
+ if (attempt > 0) await sleep(2000);
415
+ for (const target of targets) {
416
+ try {
417
+ await axios.get(target, { timeout: 5000, maxRedirects: 2, validateStatus: () => true });
418
+ return true;
419
+ } catch {} // expected: target may be unreachable through tunnel
420
+ }
421
+ }
422
+ return false;
423
+ }
424
+
425
+ /** Extract transport rate key from a V2Ray outbound for dynamic rate recording. */
426
+ function _transportRateKey(ob) {
427
+ const network = ob.streamSettings?.network;
428
+ const security = ob.streamSettings?.security || 'none';
429
+ if (!network) return null;
430
+ if (security === 'tls') return `${network}/tls`;
431
+ if (network === 'grpc') return 'grpc/none';
432
+ return network;
433
+ }
434
+
435
+ async function setupV2Ray({ remoteUrl, serverHost, sessionId, privKey, v2rayExePath, systemProxy, dns, onProgress, logFn, extremeDrift, clockDriftSec, nodeAddress, timeouts, signal, nodeAgent, state }) {
436
+ if (!v2rayExePath) throw new TunnelError(ErrorCodes.V2RAY_NOT_FOUND, 'v2rayExePath required for V2Ray nodes');
437
+
438
+ // Generate UUID for V2Ray session
439
+ const uuid = generateV2RayUUID();
440
+
441
+ // Handshake with node
442
+ checkAborted(signal);
443
+ progress(onProgress, logFn, 'handshake', 'V2Ray handshake...');
444
+ const hs = await initHandshakeV3V2Ray(remoteUrl, sessionId, privKey, uuid, nodeAgent);
445
+
446
+ // NOTE: Credentials are saved AFTER verified connectivity (not here).
447
+ // Saving before verification causes stale credentials to persist on retry
448
+ // when the tunnel fails — the node doesn't route traffic with old UUID/keys.
449
+
450
+ // Wait for node to register UUID.
451
+ // v20: Fixed 5s sleep. v21: Reduced to 2s — V2Ray outbound loop has its own
452
+ // readiness checks (waitForPort + SOCKS5 connectivity test). ~8% of V2Ray
453
+ // nodes need 5-10s to register UUID internally (node-tester-learnings-2026-03-20).
454
+ progress(onProgress, logFn, 'tunnel', 'Waiting for node to register UUID...');
455
+ await sleep(5000);
456
+
457
+ // Post-handshake viability checks (before spending time on outbound tests)
458
+ const allMeta = JSON.parse(hs.config).metadata || [];
459
+
460
+ // VMess-only nodes with extreme clock drift → guaranteed AEAD failure.
461
+ // VLess (proxy_protocol=1) is immune to clock drift; only VMess (proxy_protocol=2) fails.
462
+ if (extremeDrift) {
463
+ const hasVless = allMeta.some(m => m.proxy_protocol === 1);
464
+ if (!hasVless) {
465
+ throw new NodeError(ErrorCodes.NODE_CLOCK_DRIFT, `VMess-only node with clock drift ${clockDriftSec}s (AEAD tolerance ±120s, no VLess available)`, { clockDriftSec, nodeAddress });
466
+ }
467
+ logFn?.('VLess available — testing despite clock drift (VLess ignores clock drift)');
468
+ }
469
+
470
+ // Build config — rotating port to avoid Windows TIME_WAIT conflicts
471
+ // Sequential increment from random start avoids repeated collisions
472
+ // with TIME_WAIT ports that pure random retries can hit.
473
+ const { checkPortFree } = await import('./proxy.js');
474
+ const startPort = 10800 + Math.floor(Math.random() * 1000);
475
+ let socksPort = startPort;
476
+ for (let i = 0; i < 5; i++) {
477
+ socksPort = startPort + i;
478
+ if (await checkPortFree(socksPort)) break;
479
+ }
480
+ const config = buildV2RayClientConfig(serverHost, hs.config, uuid, socksPort, { dns: resolveDnsServers(dns), systemProxy, clockDriftSec: clockDriftSec || 0 });
481
+
482
+ // When clock drift is extreme (>120s), prefer VLess outbounds over VMess.
483
+ // VLess doesn't use AEAD timestamps so it's immune to clock drift.
484
+ // VMess AEAD rejects packets with >120s drift — guaranteed failure.
485
+ if (extremeDrift && config.outbounds.length > 1) {
486
+ config.outbounds.sort((a, b) => {
487
+ const aIsVless = a.protocol === 'vless' ? 0 : 1;
488
+ const bIsVless = b.protocol === 'vless' ? 0 : 1;
489
+ return aIsVless - bIsVless;
490
+ });
491
+ // Update routing to point to the first (now VLess) outbound
492
+ const proxyRule = config.routing.rules.find(r => r.inboundTag?.includes('proxy'));
493
+ if (proxyRule) proxyRule.outboundTag = config.outbounds[0].tag;
494
+ logFn?.(`Clock drift ${clockDriftSec}s: reordered outbounds — VLess first (immune to drift)`);
495
+ }
496
+
497
+ // Write config and start V2Ray, testing each outbound individually
498
+ // (NEVER use balancer — causes session poisoning, see known-issues.md)
499
+ const tmpDir = path.join(os.tmpdir(), 'sentinel-v2ray');
500
+ mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
501
+ const cfgPath = path.join(tmpDir, 'config.json');
502
+
503
+ let workingOutbound = null;
504
+ try {
505
+ for (const ob of config.outbounds) {
506
+ checkAborted(signal);
507
+ // Pre-connection TCP probe for TCP-based transports — skip dead ports in 3s
508
+ // instead of wasting 30s on a full V2Ray start+test cycle
509
+ const obNet = ob.streamSettings?.network;
510
+ if (['tcp', 'websocket', 'grpc', 'gun', 'http'].includes(obNet)) {
511
+ const obPort = ob.settings?.vnext?.[0]?.port;
512
+ const obHost = ob.settings?.vnext?.[0]?.address || serverHost;
513
+ if (obPort) {
514
+ const portOpen = await waitForPort(obPort, 3000, obHost);
515
+ if (!portOpen) {
516
+ const rk = _transportRateKey(ob);
517
+ if (rk) recordTransportResult(rk, false);
518
+ progress(onProgress, logFn, 'tunnel', ` ${ob.tag}: port ${obPort} not reachable, skipping`);
519
+ continue;
520
+ }
521
+ }
522
+ }
523
+
524
+ // Kill previous v2ray process by PID (NOT taskkill /IM which kills ALL v2ray.exe system-wide)
525
+ if (state.v2rayProc) {
526
+ state.v2rayProc.kill();
527
+ state.v2rayProc = null;
528
+ await sleep(2000);
529
+ }
530
+
531
+ // Config with single outbound (no balancer) — only include the outbound being tested
532
+ const attempt = {
533
+ ...config,
534
+ outbounds: [ob],
535
+ routing: {
536
+ domainStrategy: 'IPIfNonMatch',
537
+ rules: [
538
+ { inboundTag: ['api'], outboundTag: 'api', type: 'field' },
539
+ { inboundTag: ['proxy'], outboundTag: ob.tag, type: 'field' },
540
+ ],
541
+ },
542
+ };
543
+
544
+ writeFileSync(cfgPath, JSON.stringify(attempt, null, 2), { mode: 0o600 });
545
+ // Restrict ACL on Windows (temp dir is user-scoped but readable by same-user processes)
546
+ if (process.platform === 'win32') {
547
+ try { execFileSync('icacls', [cfgPath, '/inheritance:r', '/grant:r', `${process.env.USERNAME || 'BUILTIN\\Users'}:F`, '/grant:r', 'SYSTEM:F'], { stdio: 'pipe', timeout: 3000 }); } catch {}
548
+ }
549
+ const proc = spawn(v2rayExePath, ['run', '-config', cfgPath], { stdio: 'pipe' });
550
+ // Capture V2Ray stderr for diagnostics — filter out known noise lines
551
+ // "proxy/socks: insufficient header" appears on every port probe (100% of runs), not a real error.
552
+ if (proc.stderr) {
553
+ proc.stderr.on('data', (chunk) => {
554
+ const lines = chunk.toString().split('\n');
555
+ for (const line of lines) {
556
+ const trimmed = line.trim();
557
+ if (!trimmed) continue;
558
+ if (trimmed.includes('insufficient header')) continue; // port probe noise
559
+ logFn?.(`[v2ray stderr] ${trimmed}`);
560
+ }
561
+ });
562
+ }
563
+ // Delete config after V2Ray reads it (contains UUID credentials)
564
+ setTimeout(() => { try { unlinkSync(cfgPath); } catch {} }, 2000);
565
+
566
+ // Wait for SOCKS5 port to accept connections instead of fixed sleep.
567
+ // V2Ray binding is async — fixed 6s sleep causes false failures on slow starts.
568
+ const ready = await waitForPort(socksPort, timeouts.v2rayReady);
569
+ if (!ready || proc.exitCode !== null) {
570
+ progress(onProgress, logFn, 'tunnel', ` ${ob.tag}: v2ray ${proc.exitCode !== null ? `exited (code ${proc.exitCode})` : 'SOCKS5 port not ready'}, skipping`);
571
+ proc.kill();
572
+ continue;
573
+ }
574
+
575
+ // Test connectivity through SOCKS5 — use reliable targets, not httpbin.org
576
+ const TARGETS = ['https://www.google.com', 'https://www.cloudflare.com'];
577
+ let connected = false;
578
+ try {
579
+ const { SocksProxyAgent } = await import('socks-proxy-agent');
580
+ for (const target of TARGETS) {
581
+ const auth = config._socksAuth;
582
+ const proxyUrl = (auth?.user && auth?.pass)
583
+ ? `socks5://${auth.user}:${auth.pass}@127.0.0.1:${socksPort}`
584
+ : `socks5://127.0.0.1:${socksPort}`;
585
+ const agent = new SocksProxyAgent(proxyUrl);
586
+ try {
587
+ await axios.get(target, { httpAgent: agent, httpsAgent: agent, timeout: 10000, maxRedirects: 2, validateStatus: () => true });
588
+ connected = true;
589
+ break;
590
+ } catch {} finally { agent.destroy(); }
591
+ }
592
+ if (connected) {
593
+ const rk = _transportRateKey(ob);
594
+ if (rk) recordTransportResult(rk, true);
595
+ progress(onProgress, logFn, 'verify', `${ob.tag}: connected!`);
596
+ workingOutbound = ob;
597
+ state.v2rayProc = proc;
598
+ break;
599
+ }
600
+ } catch {}
601
+ if (!connected) {
602
+ const rk = _transportRateKey(ob);
603
+ if (rk) recordTransportResult(rk, false);
604
+ progress(onProgress, logFn, 'tunnel', ` ${ob.tag}: failed (no connectivity)`);
605
+ proc.kill();
606
+ }
607
+ }
608
+ } catch (err) {
609
+ // Kill any lingering V2Ray process on loop exit (abort, unexpected throw, etc.)
610
+ if (state.v2rayProc) {
611
+ try { killV2RayProc(state.v2rayProc); } catch {} // cleanup: best-effort
612
+ state.v2rayProc = null;
613
+ }
614
+ throw err;
615
+ }
616
+
617
+ if (!workingOutbound) {
618
+ clearCredentials(nodeAddress); // Clear stale handshake credentials so retry gets fresh ones
619
+ throw new TunnelError(ErrorCodes.V2RAY_ALL_FAILED, 'All V2Ray transport/protocol combinations failed', { nodeAddress, sessionId: String(sessionId) });
620
+ }
621
+
622
+ // Save credentials AFTER verified connectivity — prevents stale credentials
623
+ // from persisting when handshake succeeds but tunnel fails to route traffic.
624
+ saveCredentials(nodeAddress, String(sessionId), {
625
+ serviceType: 'v2ray',
626
+ v2rayUuid: uuid,
627
+ v2rayConfig: hs.config,
628
+ });
629
+
630
+ // Auto-set Windows system proxy so browser traffic goes through the SOCKS5 tunnel.
631
+ // Without this, V2Ray creates a local proxy but nothing uses it — the user's IP doesn't change.
632
+ if (systemProxy && socksPort) {
633
+ progress(onProgress, logFn, 'proxy', `Setting system SOCKS proxy → 127.0.0.1:${socksPort}`);
634
+ setSystemProxy(socksPort);
635
+ }
636
+
637
+ const sessionIdStr = String(sessionId); // String, not BigInt — safe for JSON.stringify
638
+ // Expose SOCKS5 auth credentials so external apps can use the proxy for split tunneling.
639
+ // Default is noauth (no credentials needed), but if socksAuth=true was passed, return creds.
640
+ const socksAuth = config._socksAuth?.user
641
+ ? { user: config._socksAuth.user, pass: config._socksAuth.pass }
642
+ : null;
643
+ saveState({ sessionId: sessionIdStr, serviceType: 'v2ray', v2rayPid: state.v2rayProc?.pid, socksPort, systemProxySet: state.systemProxy, nodeAddress });
644
+ state.connection = { sessionId: sessionIdStr, serviceType: 'v2ray', nodeAddress, socksPort, connectedAt: Date.now() };
645
+ return {
646
+ sessionId: sessionIdStr,
647
+ serviceType: 'v2ray',
648
+ nodeAddress,
649
+ socksPort,
650
+ socksAuth,
651
+ outbound: workingOutbound.tag,
652
+ cleanup: async () => {
653
+ if (state.v2rayProc) { state.v2rayProc.kill(); state.v2rayProc = null; await sleep(500); }
654
+ if (state.systemProxy) clearSystemProxy();
655
+ // End session on chain (fire-and-forget)
656
+ if (sessionIdStr && state._mnemonic) {
657
+ _endSessionOnChain(sessionIdStr, state._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
658
+ }
659
+ state.connection = null;
660
+ state._mnemonic = null;
661
+ clearState();
662
+ },
663
+ };
664
+ }
665
+
666
+ // ─── V2Ray Process Management ───────────────────────────────────────────────
667
+
668
+ /**
669
+ * Kill a V2Ray process with SIGTERM, falling back to SIGKILL if it doesn't exit.
670
+ */
671
+ export function killV2RayProc(proc) {
672
+ if (!proc) return;
673
+ try { proc.kill('SIGTERM'); } catch (e) { console.warn('[sentinel-sdk] V2Ray SIGTERM warning:', e.message); }
674
+ // Give 2s for graceful shutdown, then force kill
675
+ setTimeout(() => {
676
+ try { if (!proc.killed) proc.kill('SIGKILL'); } catch {} // SIGKILL can't be caught, truly final
677
+ }, 2000).unref();
678
+ }
679
+
680
+ /**
681
+ * Kill orphaned v2ray process if one exists from a previous crash.
682
+ * Only kills the process tracked by this module (by PID), NOT all v2ray.exe.
683
+ */
684
+ export function killOrphanV2Ray() {
685
+ for (const s of _activeStates) {
686
+ if (s.v2rayProc) {
687
+ killV2RayProc(s.v2rayProc);
688
+ s.v2rayProc = null;
689
+ }
690
+ }
691
+ }