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.
- package/CHANGELOG.md +446 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/ai-path/ADMIN-ELEVATION.md +116 -0
- package/ai-path/AI-MANIFESTO.md +185 -0
- package/ai-path/BREAKING.md +74 -0
- package/ai-path/CHECKLIST.md +619 -0
- package/ai-path/CONNECTION-STEPS.md +724 -0
- package/ai-path/DECISION-TREE.md +378 -0
- package/ai-path/DEPENDENCIES.md +459 -0
- package/ai-path/E2E-FLOW.md +1555 -0
- package/ai-path/FAILURES.md +403 -0
- package/ai-path/GUIDE.md +1217 -0
- package/ai-path/README.md +558 -0
- package/ai-path/SPLIT-TUNNEL.md +266 -0
- package/ai-path/cli.js +535 -0
- package/ai-path/connect.js +884 -0
- package/ai-path/discover.js +178 -0
- package/ai-path/environment.js +266 -0
- package/ai-path/errors.js +86 -0
- package/ai-path/examples/autonomous-agent.mjs +220 -0
- package/ai-path/examples/multi-region.mjs +174 -0
- package/ai-path/examples/one-shot.mjs +31 -0
- package/ai-path/index.js +60 -0
- package/ai-path/pricing.js +136 -0
- package/ai-path/recommend.js +413 -0
- package/ai-path/run-admin.vbs +25 -0
- package/ai-path/setup.js +291 -0
- package/ai-path/wallet.js +137 -0
- package/app-helpers.js +363 -0
- package/app-settings.js +95 -0
- package/app-types.js +267 -0
- package/audit.js +847 -0
- package/batch.js +293 -0
- package/bin/setup.js +376 -0
- package/chain/authz.js +109 -0
- package/chain/broadcast.js +472 -0
- package/chain/client.js +160 -0
- package/chain/fee-grants.js +305 -0
- package/chain/index.js +891 -0
- package/chain/lcd.js +313 -0
- package/chain/queries.js +547 -0
- package/chain/rpc.js +408 -0
- package/chain/wallet.js +141 -0
- package/cli/config.js +143 -0
- package/cli/index.js +463 -0
- package/cli/output.js +182 -0
- package/cli.js +491 -0
- package/client/index.js +251 -0
- package/client.js +271 -0
- package/config/index.js +255 -0
- package/connection/connect.js +849 -0
- package/connection/disconnect.js +180 -0
- package/connection/discovery.js +321 -0
- package/connection/index.js +76 -0
- package/connection/proxy.js +148 -0
- package/connection/resilience.js +428 -0
- package/connection/security.js +232 -0
- package/connection/state.js +369 -0
- package/connection/tunnel.js +691 -0
- package/consumer.js +132 -0
- package/cosmjs-setup.js +1884 -0
- package/defaults.js +366 -0
- package/disk-cache.js +107 -0
- package/dist/client.d.ts +108 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +400 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/errors/index.js +112 -0
- package/errors.js +218 -0
- package/examples/README.md +64 -0
- package/examples/connect-direct.mjs +106 -0
- package/examples/connect-plan.mjs +125 -0
- package/examples/error-handling.mjs +109 -0
- package/examples/query-nodes.mjs +94 -0
- package/examples/wallet-basics.mjs +61 -0
- package/generated/amino/amino.ts +9 -0
- package/generated/cosmos/base/v1beta1/coin.ts +365 -0
- package/generated/cosmos_proto/cosmos.ts +323 -0
- package/generated/gogoproto/gogo.ts +9 -0
- package/generated/google/protobuf/descriptor.ts +7601 -0
- package/generated/google/protobuf/duration.ts +208 -0
- package/generated/google/protobuf/timestamp.ts +238 -0
- package/generated/sentinel/lease/v1/events.ts +924 -0
- package/generated/sentinel/lease/v1/lease.ts +292 -0
- package/generated/sentinel/lease/v1/msg.ts +949 -0
- package/generated/sentinel/lease/v1/params.ts +164 -0
- package/generated/sentinel/node/v3/events.ts +881 -0
- package/generated/sentinel/node/v3/msg.ts +1002 -0
- package/generated/sentinel/node/v3/node.ts +263 -0
- package/generated/sentinel/node/v3/params.ts +183 -0
- package/generated/sentinel/plan/v3/events.ts +675 -0
- package/generated/sentinel/plan/v3/msg.ts +1191 -0
- package/generated/sentinel/plan/v3/plan.ts +283 -0
- package/generated/sentinel/provider/v2/events.ts +171 -0
- package/generated/sentinel/provider/v2/msg.ts +480 -0
- package/generated/sentinel/provider/v2/params.ts +131 -0
- package/generated/sentinel/provider/v2/provider.ts +246 -0
- package/generated/sentinel/session/v3/events.ts +480 -0
- package/generated/sentinel/session/v3/msg.ts +616 -0
- package/generated/sentinel/session/v3/params.ts +260 -0
- package/generated/sentinel/session/v3/proof.ts +180 -0
- package/generated/sentinel/session/v3/session.ts +384 -0
- package/generated/sentinel/subscription/v3/events.ts +1181 -0
- package/generated/sentinel/subscription/v3/msg.ts +1305 -0
- package/generated/sentinel/subscription/v3/params.ts +167 -0
- package/generated/sentinel/subscription/v3/subscription.ts +315 -0
- package/generated/sentinel/types/v1/bandwidth.ts +124 -0
- package/generated/sentinel/types/v1/price.ts +149 -0
- package/generated/sentinel/types/v1/renewal.ts +87 -0
- package/generated/sentinel/types/v1/status.ts +54 -0
- package/generated/typeRegistry.ts +27 -0
- package/index.js +486 -0
- package/node-connect.js +3015 -0
- package/operator.js +134 -0
- package/package.json +113 -0
- package/plan-operations.js +199 -0
- package/preflight.js +352 -0
- package/pricing/index.js +262 -0
- package/proto/amino/amino.proto +84 -0
- package/proto/cosmos/base/v1beta1/coin.proto +61 -0
- package/proto/cosmos_proto/cosmos.proto +112 -0
- package/proto/gogoproto/gogo.proto +145 -0
- package/proto/google/api/annotations.proto +31 -0
- package/proto/google/api/http.proto +370 -0
- package/proto/google/protobuf/any.proto +106 -0
- package/proto/google/protobuf/duration.proto +115 -0
- package/proto/google/protobuf/timestamp.proto +145 -0
- package/proto/sentinel/lease/v1/events.proto +52 -0
- package/proto/sentinel/lease/v1/genesis.proto +15 -0
- package/proto/sentinel/lease/v1/lease.proto +25 -0
- package/proto/sentinel/lease/v1/msg.proto +62 -0
- package/proto/sentinel/lease/v1/params.proto +17 -0
- package/proto/sentinel/node/v3/events.proto +50 -0
- package/proto/sentinel/node/v3/genesis.proto +15 -0
- package/proto/sentinel/node/v3/msg.proto +63 -0
- package/proto/sentinel/node/v3/node.proto +27 -0
- package/proto/sentinel/node/v3/params.proto +21 -0
- package/proto/sentinel/node/v3/querier.proto +63 -0
- package/proto/sentinel/plan/v3/events.proto +41 -0
- package/proto/sentinel/plan/v3/genesis.proto +21 -0
- package/proto/sentinel/plan/v3/msg.proto +83 -0
- package/proto/sentinel/plan/v3/plan.proto +32 -0
- package/proto/sentinel/plan/v3/querier.proto +53 -0
- package/proto/sentinel/provider/v2/events.proto +16 -0
- package/proto/sentinel/provider/v2/genesis.proto +15 -0
- package/proto/sentinel/provider/v2/msg.proto +35 -0
- package/proto/sentinel/provider/v2/params.proto +17 -0
- package/proto/sentinel/provider/v2/provider.proto +24 -0
- package/proto/sentinel/provider/v3/genesis.proto +15 -0
- package/proto/sentinel/provider/v3/params.proto +13 -0
- package/proto/sentinel/session/v3/events.proto +30 -0
- package/proto/sentinel/session/v3/genesis.proto +15 -0
- package/proto/sentinel/session/v3/msg.proto +50 -0
- package/proto/sentinel/session/v3/params.proto +25 -0
- package/proto/sentinel/session/v3/proof.proto +25 -0
- package/proto/sentinel/session/v3/querier.proto +100 -0
- package/proto/sentinel/session/v3/session.proto +50 -0
- package/proto/sentinel/subscription/v2/allocation.proto +21 -0
- package/proto/sentinel/subscription/v2/payout.proto +22 -0
- package/proto/sentinel/subscription/v3/events.proto +65 -0
- package/proto/sentinel/subscription/v3/genesis.proto +17 -0
- package/proto/sentinel/subscription/v3/msg.proto +83 -0
- package/proto/sentinel/subscription/v3/params.proto +21 -0
- package/proto/sentinel/subscription/v3/subscription.proto +33 -0
- package/proto/sentinel/types/v1/bandwidth.proto +19 -0
- package/proto/sentinel/types/v1/price.proto +21 -0
- package/proto/sentinel/types/v1/renewal.proto +21 -0
- package/proto/sentinel/types/v1/status.proto +16 -0
- package/protocol/encoding.js +341 -0
- package/protocol/events.js +361 -0
- package/protocol/handshake.js +297 -0
- package/protocol/index.js +15 -0
- package/protocol/messages.js +346 -0
- package/protocol/plans.js +199 -0
- package/protocol/v2ray.js +268 -0
- package/protocol/v3.js +723 -0
- package/protocol/wireguard.js +125 -0
- package/security/index.js +132 -0
- package/session-manager.js +329 -0
- package/session-tracker.js +80 -0
- package/setup.js +376 -0
- package/speedtest/index.js +528 -0
- package/speedtest.js +567 -0
- package/src/client.ts +502 -0
- package/src/index.ts +20 -0
- package/state/index.js +347 -0
- package/state.js +516 -0
- package/test-all-chain-ops.js +493 -0
- package/test-all-logic.js +199 -0
- package/test-all-msg-types.js +292 -0
- package/test-every-connection.js +208 -0
- package/test-feegrant-connect.js +98 -0
- package/test-logic.js +148 -0
- package/test-mainnet.js +176 -0
- package/test-plan-lifecycle.js +335 -0
- package/tls-trust.js +132 -0
- package/tsconfig.build.json +20 -0
- package/tsconfig.json +34 -0
- package/types/chain.d.ts +746 -0
- package/types/connection.d.ts +425 -0
- package/types/errors.d.ts +174 -0
- package/types/index.d.ts +1380 -0
- package/types/nodes.d.ts +187 -0
- package/types/pricing.d.ts +156 -0
- package/types/protocol.d.ts +332 -0
- package/types/session.d.ts +236 -0
- package/types/settings.d.ts +192 -0
- package/v3protocol.js +1053 -0
- package/wallet/index.js +153 -0
- package/wireguard.js +307 -0
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel AI Path — Zero-Config VPN Connection
|
|
3
|
+
*
|
|
4
|
+
* One function call: await connect({ mnemonic }) -> connected
|
|
5
|
+
*
|
|
6
|
+
* This module wraps the full Sentinel SDK into the simplest possible
|
|
7
|
+
* interface for AI agents. No config files, no setup — just connect.
|
|
8
|
+
*
|
|
9
|
+
* AGENT FLOW (7 steps, each logged):
|
|
10
|
+
* STEP 1/7 Environment — check OS, V2Ray, WireGuard, admin
|
|
11
|
+
* STEP 2/7 Wallet — derive address, connect to chain
|
|
12
|
+
* STEP 3/7 Balance — verify sufficient P2P before paying
|
|
13
|
+
* STEP 4/7 Node — select + validate target node
|
|
14
|
+
* STEP 5/7 Session — broadcast TX, create on-chain session
|
|
15
|
+
* STEP 6/7 Tunnel — handshake + install WireGuard/V2Ray
|
|
16
|
+
* STEP 7/7 Verify — confirm IP changed, traffic flows
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
connectAuto,
|
|
21
|
+
connectDirect,
|
|
22
|
+
disconnect as sdkDisconnect,
|
|
23
|
+
isConnected,
|
|
24
|
+
getStatus,
|
|
25
|
+
registerCleanupHandlers,
|
|
26
|
+
verifyConnection,
|
|
27
|
+
verifyDependencies,
|
|
28
|
+
formatP2P,
|
|
29
|
+
events,
|
|
30
|
+
createWallet as sdkCreateWallet,
|
|
31
|
+
createClient,
|
|
32
|
+
getBalance as sdkGetBalance,
|
|
33
|
+
tryWithFallback,
|
|
34
|
+
RPC_ENDPOINTS,
|
|
35
|
+
// v1.5.0: RPC queries (protobuf, ~10x faster than LCD for balance checks)
|
|
36
|
+
createRpcQueryClientWithFallback,
|
|
37
|
+
rpcQueryBalance,
|
|
38
|
+
// v1.5.0: Typed event parsers (replaces string matching for session ID extraction)
|
|
39
|
+
extractSessionIdTyped,
|
|
40
|
+
NodeEventCreateSession,
|
|
41
|
+
// v1.5.0: TYPE_URLS constants (canonical type URL strings)
|
|
42
|
+
TYPE_URLS,
|
|
43
|
+
} from '../index.js';
|
|
44
|
+
|
|
45
|
+
// Use native fetch (Node 20+) for IP check — no axios dependency needed
|
|
46
|
+
// The SDK handles axios adapter internally for tunnel traffic
|
|
47
|
+
|
|
48
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const IP_CHECK_URL = 'https://api.ipify.org?format=json';
|
|
51
|
+
const IP_CHECK_TIMEOUT = 10000;
|
|
52
|
+
const MIN_BALANCE_UDVPN = 5_000_000; // 5 P2P — realistic minimum for cheapest node (~4 P2P) + gas
|
|
53
|
+
|
|
54
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
let _cleanupRegistered = false;
|
|
57
|
+
let _lastConnectResult = null;
|
|
58
|
+
let _connectTimings = {};
|
|
59
|
+
|
|
60
|
+
// ─── Agent Logger ───────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Structured step logger for autonomous agents.
|
|
64
|
+
* Each step prints a numbered phase with timestamp.
|
|
65
|
+
* Agents can parse these lines programmatically.
|
|
66
|
+
*/
|
|
67
|
+
function agentLog(step, total, phase, msg) {
|
|
68
|
+
const ts = new Date().toISOString();
|
|
69
|
+
console.log(`[${ts}] [STEP ${step}/${total}] [${phase}] ${msg}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ensure cleanup handlers are registered (idempotent).
|
|
76
|
+
* Handles SIGINT, SIGTERM, uncaught exceptions — tears down tunnels on exit.
|
|
77
|
+
*/
|
|
78
|
+
function ensureCleanup() {
|
|
79
|
+
if (_cleanupRegistered) return;
|
|
80
|
+
registerCleanupHandlers();
|
|
81
|
+
_cleanupRegistered = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ensure axios uses Node.js HTTP adapter (not fetch) for Node 20+.
|
|
86
|
+
* Without this, SOCKS proxy and tunnel traffic silently fails.
|
|
87
|
+
* Lazy-imports axios from the SDK's node_modules.
|
|
88
|
+
*/
|
|
89
|
+
async function ensureAxiosAdapter() {
|
|
90
|
+
try {
|
|
91
|
+
const axios = (await import('axios')).default;
|
|
92
|
+
if (axios.defaults.adapter !== 'http') {
|
|
93
|
+
axios.defaults.adapter = 'http';
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// axios not available — SDK will handle this during connect
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check the public IP through the VPN tunnel to confirm it changed.
|
|
102
|
+
* For WireGuard: native fetch routes through the tunnel automatically.
|
|
103
|
+
* For V2Ray: must use SOCKS5 proxy — native fetch ignores SOCKS5.
|
|
104
|
+
* Returns the IP string or null if the check fails.
|
|
105
|
+
*/
|
|
106
|
+
async function checkVpnIp(socksPort) {
|
|
107
|
+
try {
|
|
108
|
+
if (socksPort) {
|
|
109
|
+
// V2Ray: route IP check through SOCKS5 proxy
|
|
110
|
+
// Use SDK's checkVpnIpViaSocks which has proper module resolution
|
|
111
|
+
const { checkVpnIpViaSocks } = await import('../index.js');
|
|
112
|
+
if (typeof checkVpnIpViaSocks === 'function') {
|
|
113
|
+
return await checkVpnIpViaSocks(socksPort, IP_CHECK_TIMEOUT);
|
|
114
|
+
}
|
|
115
|
+
// Fallback: use Node.js module resolution (works in every layout)
|
|
116
|
+
const axios = (await import('axios')).default;
|
|
117
|
+
const { SocksProxyAgent } = await import('socks-proxy-agent');
|
|
118
|
+
const agent = new SocksProxyAgent(`socks5h://127.0.0.1:${socksPort}`);
|
|
119
|
+
const res = await axios.get(IP_CHECK_URL, {
|
|
120
|
+
httpAgent: agent, httpsAgent: agent,
|
|
121
|
+
timeout: IP_CHECK_TIMEOUT, adapter: 'http',
|
|
122
|
+
});
|
|
123
|
+
return res.data?.ip || null;
|
|
124
|
+
}
|
|
125
|
+
// WireGuard: native fetch routes through tunnel
|
|
126
|
+
const res = await fetch(IP_CHECK_URL, {
|
|
127
|
+
signal: AbortSignal.timeout(IP_CHECK_TIMEOUT),
|
|
128
|
+
});
|
|
129
|
+
const data = await res.json();
|
|
130
|
+
return data?.ip || null;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// IP check is non-critical — tunnel may work but ipify may be blocked
|
|
133
|
+
if (err?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
134
|
+
console.warn('[sentinel-ai] IP check skipped: missing dependency —', err.message?.split("'")[1] || 'unknown');
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Convert SDK errors to human-readable messages with machine-readable nextAction.
|
|
142
|
+
* AI agents get clean, actionable error strings instead of stack traces.
|
|
143
|
+
*/
|
|
144
|
+
function humanError(err) {
|
|
145
|
+
const code = err?.code || 'UNKNOWN';
|
|
146
|
+
const msg = err?.message || String(err);
|
|
147
|
+
|
|
148
|
+
// Map common error codes to plain-English messages + next action for agent
|
|
149
|
+
const messages = {
|
|
150
|
+
INVALID_MNEMONIC: {
|
|
151
|
+
message: 'Invalid mnemonic — must be a 12 or 24 word BIP39 phrase.',
|
|
152
|
+
nextAction: 'create_wallet',
|
|
153
|
+
},
|
|
154
|
+
INSUFFICIENT_BALANCE: {
|
|
155
|
+
message: 'Wallet has insufficient P2P tokens. Fund your wallet first.',
|
|
156
|
+
nextAction: 'fund_wallet',
|
|
157
|
+
},
|
|
158
|
+
ALREADY_CONNECTED: {
|
|
159
|
+
message: 'Already connected to VPN. Call disconnect() first.',
|
|
160
|
+
nextAction: 'disconnect',
|
|
161
|
+
},
|
|
162
|
+
NODE_NOT_FOUND: {
|
|
163
|
+
message: 'Node not found or offline. Try a different node or use connectAuto.',
|
|
164
|
+
nextAction: 'try_different_node',
|
|
165
|
+
},
|
|
166
|
+
NODE_NO_UDVPN: {
|
|
167
|
+
message: 'Node does not accept P2P token payments.',
|
|
168
|
+
nextAction: 'try_different_node',
|
|
169
|
+
},
|
|
170
|
+
WG_NO_CONNECTIVITY: {
|
|
171
|
+
message: 'WireGuard tunnel installed but no traffic flows. Try a different node.',
|
|
172
|
+
nextAction: 'try_different_node',
|
|
173
|
+
},
|
|
174
|
+
V2RAY_NOT_FOUND: {
|
|
175
|
+
message: 'V2Ray binary not found. Run setup first: node setup.js',
|
|
176
|
+
nextAction: 'run_setup',
|
|
177
|
+
},
|
|
178
|
+
HANDSHAKE_FAILED: {
|
|
179
|
+
message: 'Handshake with node failed. The node may be overloaded — try another.',
|
|
180
|
+
nextAction: 'try_different_node',
|
|
181
|
+
},
|
|
182
|
+
SESSION_EXTRACT_FAILED: {
|
|
183
|
+
message: 'Session creation TX succeeded but session ID could not be extracted.',
|
|
184
|
+
nextAction: 'retry',
|
|
185
|
+
},
|
|
186
|
+
ALL_NODES_FAILED: {
|
|
187
|
+
message: 'All candidate nodes failed to connect.',
|
|
188
|
+
nextAction: 'try_different_country',
|
|
189
|
+
},
|
|
190
|
+
ABORTED: {
|
|
191
|
+
message: 'Connection was cancelled.',
|
|
192
|
+
nextAction: 'none',
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const entry = messages[code];
|
|
197
|
+
if (entry) return entry;
|
|
198
|
+
return { message: `Connection failed: ${msg}`, nextAction: 'retry' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Pre-validate balance before any connection attempt.
|
|
203
|
+
* Returns { address, udvpn, p2p, sufficient }.
|
|
204
|
+
*/
|
|
205
|
+
async function preValidateBalance(mnemonic) {
|
|
206
|
+
try {
|
|
207
|
+
const { wallet, account } = await sdkCreateWallet(mnemonic);
|
|
208
|
+
|
|
209
|
+
// v1.5.0: Try RPC query first (protobuf, ~10x faster — no signing client needed)
|
|
210
|
+
try {
|
|
211
|
+
const rpcClient = await createRpcQueryClientWithFallback();
|
|
212
|
+
const coin = await rpcQueryBalance(rpcClient, account.address, 'udvpn');
|
|
213
|
+
const udvpn = parseInt(coin.amount, 10) || 0;
|
|
214
|
+
return {
|
|
215
|
+
address: account.address,
|
|
216
|
+
udvpn,
|
|
217
|
+
p2p: formatP2P(udvpn),
|
|
218
|
+
sufficient: udvpn >= MIN_BALANCE_UDVPN,
|
|
219
|
+
};
|
|
220
|
+
} catch {
|
|
221
|
+
// RPC failed — fall back to signing client
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Fallback: signing client + sdkGetBalance (LCD-based)
|
|
225
|
+
const { result: client } = await tryWithFallback(
|
|
226
|
+
RPC_ENDPOINTS,
|
|
227
|
+
async (url) => createClient(url, wallet),
|
|
228
|
+
'RPC connect (balance pre-check)',
|
|
229
|
+
);
|
|
230
|
+
const bal = await sdkGetBalance(client, account.address);
|
|
231
|
+
return {
|
|
232
|
+
address: account.address,
|
|
233
|
+
udvpn: bal.udvpn,
|
|
234
|
+
p2p: formatP2P(bal.udvpn),
|
|
235
|
+
sufficient: bal.udvpn >= MIN_BALANCE_UDVPN,
|
|
236
|
+
};
|
|
237
|
+
} catch {
|
|
238
|
+
// Balance check failed — let connect() handle it downstream
|
|
239
|
+
return { address: null, udvpn: 0, p2p: '0 P2P', sufficient: false };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── connect() ───────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Connect to Sentinel dVPN. The ONE function an AI agent needs.
|
|
247
|
+
*
|
|
248
|
+
* Every step is logged with numbered phases (STEP 1/7 through STEP 7/7)
|
|
249
|
+
* so an autonomous agent can track progress and diagnose failures.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} opts
|
|
252
|
+
* @param {string} opts.mnemonic - BIP39 mnemonic (12 or 24 words)
|
|
253
|
+
* @param {string} [opts.country] - Preferred country code (e.g. 'US', 'DE')
|
|
254
|
+
* @param {string} [opts.nodeAddress] - Specific node (sentnode1...). Skips auto-pick.
|
|
255
|
+
* @param {string} [opts.dns] - DNS preset: 'google', 'cloudflare', 'hns'
|
|
256
|
+
* @param {string} [opts.protocol] - Preferred protocol: 'wireguard' or 'v2ray'
|
|
257
|
+
* @param {function} [opts.onProgress] - Progress callback: (stage, message) => void
|
|
258
|
+
* @param {number} [opts.timeout] - Connection timeout in ms (default: 120000 — 2 minutes)
|
|
259
|
+
* @param {boolean} [opts.silent] - If true, suppress step-by-step console output
|
|
260
|
+
* @returns {Promise<{
|
|
261
|
+
* sessionId: string,
|
|
262
|
+
* protocol: string,
|
|
263
|
+
* nodeAddress: string,
|
|
264
|
+
* country: string|null,
|
|
265
|
+
* city: string|null,
|
|
266
|
+
* moniker: string|null,
|
|
267
|
+
* socksPort: number|null,
|
|
268
|
+
* socksAuth: object|null,
|
|
269
|
+
* dryRun: boolean,
|
|
270
|
+
* ip: string|null,
|
|
271
|
+
* walletAddress: string,
|
|
272
|
+
* balance: { before: string, after: string|null },
|
|
273
|
+
* cost: { estimated: string },
|
|
274
|
+
* timing: { totalMs: number, phases: object },
|
|
275
|
+
* }>}
|
|
276
|
+
*/
|
|
277
|
+
export async function connect(opts = {}) {
|
|
278
|
+
if (!opts || typeof opts !== 'object') {
|
|
279
|
+
throw new Error('connect() requires an options object with at least { mnemonic }');
|
|
280
|
+
}
|
|
281
|
+
if (!opts.mnemonic || typeof opts.mnemonic !== 'string') {
|
|
282
|
+
throw new Error('connect() requires a mnemonic string (12 or 24 word BIP39 phrase)');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const silent = opts.silent === true;
|
|
286
|
+
const log = silent ? () => {} : agentLog;
|
|
287
|
+
const totalSteps = 7;
|
|
288
|
+
const timings = {};
|
|
289
|
+
const connectStart = Date.now();
|
|
290
|
+
|
|
291
|
+
// ── STEP 1/7: Environment ─────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
let t0 = Date.now();
|
|
294
|
+
log(1, totalSteps, 'ENVIRONMENT', 'Checking OS, tunnel binaries, admin privileges...');
|
|
295
|
+
|
|
296
|
+
await ensureAxiosAdapter();
|
|
297
|
+
ensureCleanup();
|
|
298
|
+
|
|
299
|
+
// Detect environment for agent visibility
|
|
300
|
+
let envInfo = { os: process.platform, admin: false, v2ray: false, wireguard: false };
|
|
301
|
+
try {
|
|
302
|
+
const { getEnvironment } = await import('./environment.js');
|
|
303
|
+
const env = getEnvironment();
|
|
304
|
+
envInfo = {
|
|
305
|
+
os: env.os,
|
|
306
|
+
admin: env.admin,
|
|
307
|
+
v2ray: env.v2ray?.available || false,
|
|
308
|
+
wireguard: env.wireguard?.available || false,
|
|
309
|
+
v2rayPath: env.v2ray?.path || null,
|
|
310
|
+
};
|
|
311
|
+
} catch { /* environment detection failed */ }
|
|
312
|
+
|
|
313
|
+
log(1, totalSteps, 'ENVIRONMENT', `OS=${envInfo.os} | admin=${envInfo.admin} | v2ray=${envInfo.v2ray} | wireguard=${envInfo.wireguard}`);
|
|
314
|
+
timings.environment = Date.now() - t0;
|
|
315
|
+
|
|
316
|
+
// ── STEP 2/7: Wallet ──────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
t0 = Date.now();
|
|
319
|
+
log(2, totalSteps, 'WALLET', 'Deriving wallet address from mnemonic...');
|
|
320
|
+
|
|
321
|
+
// We derive address early for agent visibility (before SDK does it internally)
|
|
322
|
+
let walletAddress = null;
|
|
323
|
+
try {
|
|
324
|
+
const { account } = await sdkCreateWallet(opts.mnemonic);
|
|
325
|
+
walletAddress = account.address;
|
|
326
|
+
log(2, totalSteps, 'WALLET', `Address: ${walletAddress}`);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
log(2, totalSteps, 'WALLET', `Failed: ${err.message}`);
|
|
329
|
+
throw new Error('Invalid mnemonic — wallet derivation failed');
|
|
330
|
+
}
|
|
331
|
+
timings.wallet = Date.now() - t0;
|
|
332
|
+
|
|
333
|
+
// ── STEP 3/7: Balance Pre-Check ───────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
t0 = Date.now();
|
|
336
|
+
log(3, totalSteps, 'BALANCE', `Checking balance for ${walletAddress}...`);
|
|
337
|
+
|
|
338
|
+
const balCheck = await preValidateBalance(opts.mnemonic);
|
|
339
|
+
log(3, totalSteps, 'BALANCE', `Balance: ${balCheck.p2p} | Sufficient: ${balCheck.sufficient}`);
|
|
340
|
+
|
|
341
|
+
if (!balCheck.sufficient && !opts.dryRun) {
|
|
342
|
+
const err = new Error(`Insufficient balance: ${balCheck.p2p}. Need at least ${formatP2P(MIN_BALANCE_UDVPN)}. Fund address: ${walletAddress}`);
|
|
343
|
+
err.code = 'INSUFFICIENT_BALANCE';
|
|
344
|
+
err.nextAction = 'fund_wallet';
|
|
345
|
+
err.details = { address: walletAddress, balance: balCheck.p2p, minimum: formatP2P(MIN_BALANCE_UDVPN) };
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
timings.balance = Date.now() - t0;
|
|
349
|
+
|
|
350
|
+
// ── STEP 4/7: Node Selection ──────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
t0 = Date.now();
|
|
353
|
+
|
|
354
|
+
// Build SDK options — forward ALL documented options to the underlying SDK.
|
|
355
|
+
const sdkOpts = {
|
|
356
|
+
mnemonic: opts.mnemonic,
|
|
357
|
+
onProgress: (stage, msg) => {
|
|
358
|
+
if (opts.onProgress) opts.onProgress(stage, msg);
|
|
359
|
+
const stageMap = {
|
|
360
|
+
'wallet': 2, 'node-check': 4, 'validate': 4,
|
|
361
|
+
'session': 5, 'handshake': 6, 'tunnel': 6,
|
|
362
|
+
'verify': 7, 'dry-run': 7,
|
|
363
|
+
};
|
|
364
|
+
const step = stageMap[stage] || 5;
|
|
365
|
+
const phase = stage.toUpperCase().replace('-', '_');
|
|
366
|
+
if (!silent) agentLog(step, totalSteps, phase, msg);
|
|
367
|
+
// BUG-2 fix: capture node metadata from progress callback
|
|
368
|
+
// Format: "MonkerName (protocol) - City, Country"
|
|
369
|
+
if (stage === 'node-check' && msg && !sdkOpts._discoveredNode) {
|
|
370
|
+
const match = msg.match(/^(.+?)\s+\((\w+)\)\s+-\s+(.+?),\s+(.+)$/);
|
|
371
|
+
if (match) {
|
|
372
|
+
sdkOpts._discoveredNode = {
|
|
373
|
+
moniker: match[1],
|
|
374
|
+
serviceType: match[2],
|
|
375
|
+
city: match[3],
|
|
376
|
+
country: match[4],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
log: (msg) => {
|
|
382
|
+
if (opts.onProgress) opts.onProgress('log', msg);
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// DNS
|
|
387
|
+
if (opts.dns) sdkOpts.dns = opts.dns;
|
|
388
|
+
|
|
389
|
+
// Protocol preference — search BOTH protocols when not specified
|
|
390
|
+
if (opts.protocol === 'wireguard') sdkOpts.serviceType = 'wireguard';
|
|
391
|
+
else if (opts.protocol === 'v2ray') sdkOpts.serviceType = 'v2ray';
|
|
392
|
+
// When no protocol specified: do NOT set serviceType — let SDK try all node types
|
|
393
|
+
// This ensures both WireGuard AND V2Ray nodes are candidates
|
|
394
|
+
|
|
395
|
+
// Session pricing
|
|
396
|
+
if (opts.gigabytes && opts.gigabytes > 0) sdkOpts.gigabytes = opts.gigabytes;
|
|
397
|
+
if (opts.hours && opts.hours > 0) sdkOpts.hours = opts.hours;
|
|
398
|
+
|
|
399
|
+
// Tunnel options
|
|
400
|
+
if (opts.fullTunnel === false) sdkOpts.fullTunnel = false;
|
|
401
|
+
if (opts.killSwitch === true) sdkOpts.killSwitch = true;
|
|
402
|
+
if (opts.systemProxy !== undefined) sdkOpts.systemProxy = opts.systemProxy;
|
|
403
|
+
|
|
404
|
+
// Split tunnel — WireGuard: route only specific IPs through VPN
|
|
405
|
+
if (opts.splitIPs && Array.isArray(opts.splitIPs) && opts.splitIPs.length > 0) {
|
|
406
|
+
sdkOpts.splitIPs = opts.splitIPs;
|
|
407
|
+
sdkOpts.fullTunnel = false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// V2Ray SOCKS5 auth
|
|
411
|
+
if (opts.socksAuth === true) sdkOpts.socksAuth = true;
|
|
412
|
+
|
|
413
|
+
// V2Ray binary path
|
|
414
|
+
if (opts.v2rayExePath) {
|
|
415
|
+
sdkOpts.v2rayExePath = opts.v2rayExePath;
|
|
416
|
+
} else if (envInfo.v2rayPath) {
|
|
417
|
+
sdkOpts.v2rayExePath = envInfo.v2rayPath;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Max connection attempts
|
|
421
|
+
if (opts.maxAttempts && opts.maxAttempts > 0) sdkOpts.maxAttempts = opts.maxAttempts;
|
|
422
|
+
|
|
423
|
+
// Dry run
|
|
424
|
+
if (opts.dryRun === true) sdkOpts.dryRun = true;
|
|
425
|
+
|
|
426
|
+
// Force new session
|
|
427
|
+
if (opts.forceNewSession === true) sdkOpts.forceNewSession = true;
|
|
428
|
+
|
|
429
|
+
// AbortController
|
|
430
|
+
const timeoutMs = (opts.timeout && opts.timeout > 0) ? opts.timeout : 120000;
|
|
431
|
+
const ac = new AbortController();
|
|
432
|
+
const timeoutId = setTimeout(() => ac.abort(), timeoutMs);
|
|
433
|
+
if (opts.signal) {
|
|
434
|
+
if (opts.signal.aborted) { ac.abort(); } else {
|
|
435
|
+
opts.signal.addEventListener('abort', () => ac.abort(), { once: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
sdkOpts.signal = ac.signal;
|
|
439
|
+
|
|
440
|
+
// ── Country-aware node discovery ──────────────────────────────────────
|
|
441
|
+
// When a country is specified, connectAuto's default probe of 9 random nodes
|
|
442
|
+
// is too small to find nodes in rare countries (e.g., Singapore = 2 of 1037).
|
|
443
|
+
// Instead, we discover nodes in that country first, then connectDirect to one.
|
|
444
|
+
// This probes up to 200 nodes to find country matches, searching BOTH protocols.
|
|
445
|
+
|
|
446
|
+
let resolvedNodeAddress = opts.nodeAddress || null;
|
|
447
|
+
|
|
448
|
+
if (!resolvedNodeAddress && opts.country) {
|
|
449
|
+
const countryUpper = opts.country.toUpperCase();
|
|
450
|
+
log(4, totalSteps, 'NODE', `Discovering nodes in ${countryUpper} (probing both WireGuard + V2Ray)...`);
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const { queryOnlineNodes, filterNodes, COUNTRY_MAP } = await import('../index.js');
|
|
454
|
+
|
|
455
|
+
// Probe a large sample WITHOUT protocol filter — find ALL country matches
|
|
456
|
+
const probeCount = Math.max(200, (opts.maxAttempts || 3) * 50);
|
|
457
|
+
const allProbed = await queryOnlineNodes({
|
|
458
|
+
maxNodes: probeCount,
|
|
459
|
+
onNodeProbed: ({ total, probed, online }) => {
|
|
460
|
+
if (probed % 50 === 0 || probed === total) {
|
|
461
|
+
log(4, totalSteps, 'NODE', `Probed ${probed}/${total} nodes, ${online} online...`);
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Resolve country: filterNodes uses includes() on country NAME, not ISO code.
|
|
467
|
+
// If agent passed "SG", we need "Singapore" for filterNodes to match.
|
|
468
|
+
// Build reverse map: ISO code → country name
|
|
469
|
+
let countryFilter = countryUpper;
|
|
470
|
+
if (COUNTRY_MAP && countryUpper.length === 2) {
|
|
471
|
+
// COUNTRY_MAP is { 'singapore': 'SG', ... } — reverse lookup
|
|
472
|
+
for (const [name, code] of Object.entries(COUNTRY_MAP)) {
|
|
473
|
+
if (code === countryUpper) {
|
|
474
|
+
countryFilter = name; // "singapore" — filterNodes lowercases both sides
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Filter by country — use the resolved name (e.g., "singapore" not "SG")
|
|
481
|
+
let countryNodes = filterNodes(allProbed, { country: countryFilter });
|
|
482
|
+
let wgNodes = countryNodes.filter(n => n.serviceType === 'wireguard');
|
|
483
|
+
let v2Nodes = countryNodes.filter(n => n.serviceType === 'v2ray');
|
|
484
|
+
|
|
485
|
+
log(4, totalSteps, 'NODE', `Found ${countryNodes.length} nodes in ${countryUpper}: ${wgNodes.length} WireGuard, ${v2Nodes.length} V2Ray`);
|
|
486
|
+
|
|
487
|
+
// If initial sample missed the country, do a FULL scan of all nodes.
|
|
488
|
+
// Rare countries (e.g., Singapore = 2 of 1037) need the full network scan.
|
|
489
|
+
if (countryNodes.length === 0) {
|
|
490
|
+
log(4, totalSteps, 'NODE', `${countryUpper} not in initial sample. Scanning ALL nodes (this takes ~2 min)...`);
|
|
491
|
+
const fullProbed = await queryOnlineNodes({
|
|
492
|
+
maxNodes: 5000, // All nodes
|
|
493
|
+
onNodeProbed: ({ total, probed, online }) => {
|
|
494
|
+
if (probed % 100 === 0 || probed === total) {
|
|
495
|
+
log(4, totalSteps, 'NODE', `Full scan: ${probed}/${total} probed, ${online} online...`);
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
countryNodes = filterNodes(fullProbed, { country: countryFilter });
|
|
500
|
+
wgNodes = countryNodes.filter(n => n.serviceType === 'wireguard');
|
|
501
|
+
v2Nodes = countryNodes.filter(n => n.serviceType === 'v2ray');
|
|
502
|
+
log(4, totalSteps, 'NODE', `Full scan: ${countryNodes.length} nodes in ${countryUpper}: ${wgNodes.length} WireGuard, ${v2Nodes.length} V2Ray`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (countryNodes.length > 0) {
|
|
506
|
+
// Pick best node: prefer requested protocol, then WireGuard (faster), then V2Ray
|
|
507
|
+
let picked;
|
|
508
|
+
if (opts.protocol === 'wireguard' && wgNodes.length > 0) {
|
|
509
|
+
picked = wgNodes[0]; // Already sorted by quality score
|
|
510
|
+
} else if (opts.protocol === 'v2ray' && v2Nodes.length > 0) {
|
|
511
|
+
picked = v2Nodes[0];
|
|
512
|
+
} else if (wgNodes.length > 0 && envInfo.admin) {
|
|
513
|
+
picked = wgNodes[0]; // WireGuard preferred when admin
|
|
514
|
+
} else if (v2Nodes.length > 0) {
|
|
515
|
+
picked = v2Nodes[0];
|
|
516
|
+
} else {
|
|
517
|
+
picked = countryNodes[0];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
resolvedNodeAddress = picked.address;
|
|
521
|
+
// Store discovered node metadata for the result object
|
|
522
|
+
sdkOpts._discoveredNode = {
|
|
523
|
+
country: picked.country || null,
|
|
524
|
+
city: picked.city || null,
|
|
525
|
+
moniker: picked.moniker || null,
|
|
526
|
+
serviceType: picked.serviceType || null,
|
|
527
|
+
qualityScore: picked.qualityScore || 0,
|
|
528
|
+
};
|
|
529
|
+
log(4, totalSteps, 'NODE', `Selected: ${picked.address} (${picked.serviceType}) — ${picked.moniker || 'unnamed'}, ${picked.country}, score=${picked.qualityScore}`);
|
|
530
|
+
} else {
|
|
531
|
+
log(4, totalSteps, 'NODE', `No nodes found in ${countryUpper}. Falling back to global auto-select.`);
|
|
532
|
+
}
|
|
533
|
+
} catch (err) {
|
|
534
|
+
log(4, totalSteps, 'NODE', `Country discovery failed: ${err.message}. Falling back to auto-select.`);
|
|
535
|
+
}
|
|
536
|
+
} else if (!resolvedNodeAddress) {
|
|
537
|
+
log(4, totalSteps, 'NODE', 'Auto-selecting best available node (all countries, both protocols)...');
|
|
538
|
+
} else {
|
|
539
|
+
log(4, totalSteps, 'NODE', `Direct node: ${resolvedNodeAddress}`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
timings.nodeSelection = Date.now() - t0;
|
|
543
|
+
|
|
544
|
+
// ── STEP 5/7 + 6/7: Session + Tunnel (handled by SDK internally) ─────────
|
|
545
|
+
|
|
546
|
+
t0 = Date.now();
|
|
547
|
+
log(5, totalSteps, 'SESSION', 'Broadcasting session transaction...');
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
let result;
|
|
551
|
+
|
|
552
|
+
if (resolvedNodeAddress) {
|
|
553
|
+
// Direct connection — either user specified nodeAddress or country discovery found one
|
|
554
|
+
sdkOpts.nodeAddress = resolvedNodeAddress;
|
|
555
|
+
result = await connectDirect(sdkOpts);
|
|
556
|
+
} else {
|
|
557
|
+
// No country filter or country discovery found nothing — auto-select globally
|
|
558
|
+
// Use higher maxAttempts to search more nodes
|
|
559
|
+
if (!sdkOpts.maxAttempts) sdkOpts.maxAttempts = 5;
|
|
560
|
+
result = await connectAuto(sdkOpts);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
timings.sessionAndTunnel = Date.now() - t0;
|
|
564
|
+
|
|
565
|
+
// ── STEP 7/7: Verify ──────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
t0 = Date.now();
|
|
568
|
+
log(7, totalSteps, 'VERIFY', 'Checking VPN IP through tunnel...');
|
|
569
|
+
|
|
570
|
+
const ip = await checkVpnIp(result.socksPort || null);
|
|
571
|
+
log(7, totalSteps, 'VERIFY', ip ? `VPN IP: ${ip}` : 'IP check failed (tunnel may still work)');
|
|
572
|
+
|
|
573
|
+
timings.verify = Date.now() - t0;
|
|
574
|
+
timings.total = Date.now() - connectStart;
|
|
575
|
+
|
|
576
|
+
// ── Post-connect balance check (single RPC call — fixes BUG-3) ─────
|
|
577
|
+
|
|
578
|
+
let balanceAfter = null;
|
|
579
|
+
let costUdvpn = 0;
|
|
580
|
+
let costFormatted = 'unknown';
|
|
581
|
+
try {
|
|
582
|
+
const postBal = await preValidateBalance(opts.mnemonic);
|
|
583
|
+
balanceAfter = postBal.p2p;
|
|
584
|
+
costUdvpn = Math.max(0, balCheck.udvpn - postBal.udvpn);
|
|
585
|
+
costFormatted = formatP2P(costUdvpn);
|
|
586
|
+
} catch { /* non-critical — tunnel works even if balance check fails */ }
|
|
587
|
+
|
|
588
|
+
// ── Build agent-friendly return object ───────────────────────────────
|
|
589
|
+
|
|
590
|
+
// Pull country/city/moniker from: discovered node metadata > SDK result > onProgress capture
|
|
591
|
+
const discovered = sdkOpts._discoveredNode || {};
|
|
592
|
+
|
|
593
|
+
const output = {
|
|
594
|
+
sessionId: String(result.sessionId),
|
|
595
|
+
protocol: result.serviceType || discovered.serviceType || 'unknown',
|
|
596
|
+
nodeAddress: result.nodeAddress || resolvedNodeAddress || 'unknown',
|
|
597
|
+
country: result.nodeLocation?.country || discovered.country || null,
|
|
598
|
+
city: result.nodeLocation?.city || discovered.city || null,
|
|
599
|
+
moniker: result.nodeMoniker || discovered.moniker || null,
|
|
600
|
+
socksPort: result.socksPort || null,
|
|
601
|
+
socksAuth: result.socksAuth || null,
|
|
602
|
+
dryRun: result.dryRun || false,
|
|
603
|
+
ip,
|
|
604
|
+
walletAddress: walletAddress || balCheck.address,
|
|
605
|
+
balance: {
|
|
606
|
+
before: balCheck.p2p,
|
|
607
|
+
after: balanceAfter,
|
|
608
|
+
},
|
|
609
|
+
cost: {
|
|
610
|
+
udvpn: costUdvpn,
|
|
611
|
+
p2p: costFormatted,
|
|
612
|
+
},
|
|
613
|
+
timing: {
|
|
614
|
+
totalMs: timings.total,
|
|
615
|
+
totalFormatted: `${(timings.total / 1000).toFixed(1)}s`,
|
|
616
|
+
phases: { ...timings },
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
_lastConnectResult = output;
|
|
621
|
+
_lastConnectResult._connectedAt = Date.now(); // BUG-4 fix: store actual connect timestamp for uptime
|
|
622
|
+
_connectTimings = timings;
|
|
623
|
+
|
|
624
|
+
// ── Final summary ──────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
log(7, totalSteps, 'COMPLETE', [
|
|
627
|
+
`Session=${output.sessionId}`,
|
|
628
|
+
`Protocol=${output.protocol}`,
|
|
629
|
+
`Node=${output.nodeAddress}`,
|
|
630
|
+
output.country ? `Country=${output.country}` : null,
|
|
631
|
+
`IP=${output.ip || 'unknown'}`,
|
|
632
|
+
`Time=${output.timing.totalFormatted}`,
|
|
633
|
+
`Balance=${output.balance.before} → ${output.balance.after || '?'}`,
|
|
634
|
+
].filter(Boolean).join(' | '));
|
|
635
|
+
|
|
636
|
+
return output;
|
|
637
|
+
} catch (err) {
|
|
638
|
+
timings.total = Date.now() - connectStart;
|
|
639
|
+
const { message, nextAction } = humanError(err);
|
|
640
|
+
const wrapped = new Error(message);
|
|
641
|
+
wrapped.code = err?.code || 'UNKNOWN';
|
|
642
|
+
wrapped.nextAction = nextAction;
|
|
643
|
+
wrapped.details = err?.details || null;
|
|
644
|
+
wrapped.timing = { totalMs: timings.total, phases: { ...timings } };
|
|
645
|
+
|
|
646
|
+
log(5, totalSteps, 'FAILED', `${wrapped.code}: ${message} → nextAction: ${nextAction}`);
|
|
647
|
+
throw wrapped;
|
|
648
|
+
} finally {
|
|
649
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ─── disconnect() ────────────────────────────────────────────────────────────
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Disconnect from VPN. Tears down tunnel, cleans up system state.
|
|
657
|
+
* Returns session cost and remaining balance for agent accounting.
|
|
658
|
+
*
|
|
659
|
+
* @returns {Promise<{
|
|
660
|
+
* disconnected: boolean,
|
|
661
|
+
* sessionId: string|null,
|
|
662
|
+
* balance: string|null,
|
|
663
|
+
* timing: { connectedMs: number|null },
|
|
664
|
+
* }>}
|
|
665
|
+
*/
|
|
666
|
+
export async function disconnect() {
|
|
667
|
+
const prevResult = _lastConnectResult;
|
|
668
|
+
const sessionId = prevResult?.sessionId || null;
|
|
669
|
+
|
|
670
|
+
agentLog(1, 1, 'DISCONNECT', `Ending session${sessionId ? ` ${sessionId}` : ''}...`);
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
await sdkDisconnect();
|
|
674
|
+
|
|
675
|
+
// Check remaining balance after disconnect
|
|
676
|
+
let balance = null;
|
|
677
|
+
if (prevResult?.walletAddress) {
|
|
678
|
+
try {
|
|
679
|
+
// Re-derive from stored result isn't possible without mnemonic.
|
|
680
|
+
// Listen for the session-end event from SDK instead.
|
|
681
|
+
balance = prevResult.balance?.after || null;
|
|
682
|
+
} catch { /* non-critical */ }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const output = {
|
|
686
|
+
disconnected: true,
|
|
687
|
+
sessionId,
|
|
688
|
+
balance,
|
|
689
|
+
timing: {
|
|
690
|
+
connectedMs: prevResult?._connectedAt
|
|
691
|
+
? Date.now() - prevResult._connectedAt
|
|
692
|
+
: null,
|
|
693
|
+
setupMs: prevResult?.timing?.totalMs || null,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
agentLog(1, 1, 'DISCONNECT', `Done. Session ${sessionId || 'unknown'} ended.`);
|
|
698
|
+
|
|
699
|
+
_lastConnectResult = null;
|
|
700
|
+
_connectTimings = {};
|
|
701
|
+
return output;
|
|
702
|
+
} catch (err) {
|
|
703
|
+
_lastConnectResult = null;
|
|
704
|
+
_connectTimings = {};
|
|
705
|
+
throw new Error(`Disconnect failed: ${err.message}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ─── status() ────────────────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Get current VPN connection status.
|
|
713
|
+
* Returns everything an agent needs to assess the connection.
|
|
714
|
+
*
|
|
715
|
+
* @returns {{
|
|
716
|
+
* connected: boolean,
|
|
717
|
+
* sessionId?: string,
|
|
718
|
+
* protocol?: string,
|
|
719
|
+
* nodeAddress?: string,
|
|
720
|
+
* country?: string,
|
|
721
|
+
* city?: string,
|
|
722
|
+
* socksPort?: number,
|
|
723
|
+
* uptimeMs?: number,
|
|
724
|
+
* uptimeFormatted?: string,
|
|
725
|
+
* ip?: string|null,
|
|
726
|
+
* balance?: { before: string, after: string|null },
|
|
727
|
+
* }}
|
|
728
|
+
*/
|
|
729
|
+
export function status() {
|
|
730
|
+
const sdkStatus = getStatus();
|
|
731
|
+
|
|
732
|
+
if (!sdkStatus) {
|
|
733
|
+
return { connected: false };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
connected: true,
|
|
738
|
+
sessionId: sdkStatus.sessionId || null,
|
|
739
|
+
protocol: sdkStatus.serviceType || null,
|
|
740
|
+
nodeAddress: sdkStatus.nodeAddress || null,
|
|
741
|
+
country: _lastConnectResult?.country || null,
|
|
742
|
+
city: _lastConnectResult?.city || null,
|
|
743
|
+
socksPort: sdkStatus.socksPort || null,
|
|
744
|
+
uptimeMs: sdkStatus.uptimeMs || 0,
|
|
745
|
+
uptimeFormatted: sdkStatus.uptimeFormatted || '0s',
|
|
746
|
+
ip: _lastConnectResult?.ip || null,
|
|
747
|
+
balance: _lastConnectResult?.balance || null,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ─── isVpnActive() ──────────────────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Quick boolean check: is the VPN tunnel active right now?
|
|
755
|
+
*
|
|
756
|
+
* @returns {boolean}
|
|
757
|
+
*/
|
|
758
|
+
export function isVpnActive() {
|
|
759
|
+
return isConnected();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ─── verify() ───────────────────────────────────────────────────────────────
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Verify the VPN connection is actually working.
|
|
766
|
+
* Checks: tunnel is up, traffic flows, IP has changed.
|
|
767
|
+
*
|
|
768
|
+
* @returns {Promise<{connected: boolean, ip: string|null, verified: boolean}>}
|
|
769
|
+
*/
|
|
770
|
+
export async function verify() {
|
|
771
|
+
if (!isConnected()) {
|
|
772
|
+
return { connected: false, ip: null, verified: false };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Check IP through tunnel with latency measurement
|
|
776
|
+
const socksPort = _lastConnectResult?.socksPort || null;
|
|
777
|
+
const t0 = Date.now();
|
|
778
|
+
const ip = await checkVpnIp(socksPort);
|
|
779
|
+
const latency = Date.now() - t0;
|
|
780
|
+
|
|
781
|
+
// Try SDK's built-in verification if available
|
|
782
|
+
let sdkVerified = false;
|
|
783
|
+
try {
|
|
784
|
+
if (typeof verifyConnection === 'function') {
|
|
785
|
+
const result = await verifyConnection();
|
|
786
|
+
sdkVerified = !!result;
|
|
787
|
+
}
|
|
788
|
+
} catch {
|
|
789
|
+
// verifyConnection may not exist or may fail — IP check is sufficient
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
connected: true,
|
|
794
|
+
ip,
|
|
795
|
+
verified: ip !== null || sdkVerified,
|
|
796
|
+
latency,
|
|
797
|
+
protocol: _lastConnectResult?.protocol || null,
|
|
798
|
+
nodeAddress: _lastConnectResult?.nodeAddress || null,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ─── verifySplitTunnel() ─────────────────────────────────────────────────────
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Verify split tunneling is working correctly.
|
|
806
|
+
* For V2Ray: confirms SOCKS5 proxy routes traffic through VPN while direct traffic bypasses.
|
|
807
|
+
* For WireGuard: confirms tunnel is active (split tunnel verification requires known static IPs).
|
|
808
|
+
*
|
|
809
|
+
* IMPORTANT: Uses axios + SocksProxyAgent — NOT native fetch (which ignores SOCKS5).
|
|
810
|
+
*
|
|
811
|
+
* @returns {Promise<{splitTunnel: boolean, proxyIp: string|null, directIp: string|null, protocol: string|null}>}
|
|
812
|
+
*/
|
|
813
|
+
export async function verifySplitTunnel() {
|
|
814
|
+
if (!isConnected()) {
|
|
815
|
+
return { splitTunnel: false, proxyIp: null, directIp: null, protocol: null };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const socksPort = _lastConnectResult?.socksPort || null;
|
|
819
|
+
const protocol = _lastConnectResult?.protocol || null;
|
|
820
|
+
|
|
821
|
+
// Get direct IP (bypasses VPN)
|
|
822
|
+
let directIp = null;
|
|
823
|
+
try {
|
|
824
|
+
if (socksPort) {
|
|
825
|
+
// V2Ray: native fetch goes direct (this is correct — it proves split tunnel)
|
|
826
|
+
const res = await fetch(IP_CHECK_URL, { signal: AbortSignal.timeout(IP_CHECK_TIMEOUT) });
|
|
827
|
+
const data = await res.json();
|
|
828
|
+
directIp = data?.ip || null;
|
|
829
|
+
}
|
|
830
|
+
} catch { /* non-critical */ }
|
|
831
|
+
|
|
832
|
+
// Get proxy IP (through VPN)
|
|
833
|
+
const proxyIp = await checkVpnIp(socksPort);
|
|
834
|
+
|
|
835
|
+
// Split tunnel works when proxy and direct show different IPs
|
|
836
|
+
const splitTunnel = !!(proxyIp && directIp && proxyIp !== directIp);
|
|
837
|
+
|
|
838
|
+
return { splitTunnel, proxyIp, directIp, protocol };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ─── onEvent() ──────────────────────────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Subscribe to VPN connection events (progress, errors, reconnect).
|
|
845
|
+
*
|
|
846
|
+
* Event types:
|
|
847
|
+
* 'progress' — { step, detail } during connection
|
|
848
|
+
* 'connected' — connection established
|
|
849
|
+
* 'disconnected' — connection closed
|
|
850
|
+
* 'error' — { code, message } on failure
|
|
851
|
+
* 'reconnecting' — auto-reconnect in progress
|
|
852
|
+
*
|
|
853
|
+
* @param {function} callback - (eventType: string, data: object) => void
|
|
854
|
+
* @returns {function} unsubscribe — call to stop listening
|
|
855
|
+
*/
|
|
856
|
+
export function onEvent(callback) {
|
|
857
|
+
if (!events || typeof events.on !== 'function') {
|
|
858
|
+
// SDK events not available — return no-op unsubscribe
|
|
859
|
+
return () => {};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Subscribe to all relevant events — store exact handler refs for clean unsubscribe
|
|
863
|
+
const eventNames = [
|
|
864
|
+
'progress', 'connected', 'disconnected', 'error',
|
|
865
|
+
'reconnecting', 'reconnected', 'sessionEnd', 'sessionEndFailed',
|
|
866
|
+
];
|
|
867
|
+
|
|
868
|
+
const handlers = new Map();
|
|
869
|
+
for (const name of eventNames) {
|
|
870
|
+
const h = (data) => {
|
|
871
|
+
try { callback(name, data); } catch { /* don't crash SDK */ }
|
|
872
|
+
};
|
|
873
|
+
handlers.set(name, h);
|
|
874
|
+
events.on(name, h);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Return unsubscribe function — removes exact handler references
|
|
878
|
+
return () => {
|
|
879
|
+
for (const [name, h] of handlers) {
|
|
880
|
+
events.removeListener(name, h);
|
|
881
|
+
}
|
|
882
|
+
handlers.clear();
|
|
883
|
+
};
|
|
884
|
+
}
|