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
package/protocol/v3.js
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel v3 Node Protocol
|
|
3
|
+
*
|
|
4
|
+
* The chain upgrade changed all node REST API endpoints.
|
|
5
|
+
* v2: GET /status, POST /accounts/{addr}/sessions/{id}
|
|
6
|
+
* v3: GET / (info), POST / (handshake)
|
|
7
|
+
*
|
|
8
|
+
* The handshake request body is completely different in v3:
|
|
9
|
+
* - data: base64(JSON.stringify({public_key: "<base64_wg_pubkey>"}))
|
|
10
|
+
* - id: session ID (uint64 number)
|
|
11
|
+
* - pub_key: "secp256k1:<base64_cosmos_pubkey>"
|
|
12
|
+
* - signature: base64(secp256k1_sign(SHA256(BigEndian8(id) + data_bytes)))
|
|
13
|
+
*
|
|
14
|
+
* Sources verified from:
|
|
15
|
+
* github.com/sentinel-official/dvpn-node development branch (Dec 2025, v8.3.1)
|
|
16
|
+
* github.com/sentinel-official/sentinel-go-sdk main branch
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import https from 'https';
|
|
20
|
+
import net from 'net';
|
|
21
|
+
import axios from 'axios';
|
|
22
|
+
import { randomBytes } from 'crypto';
|
|
23
|
+
import { Secp256k1, sha256 } from '@cosmjs/crypto';
|
|
24
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
25
|
+
import { secp256k1 as nobleSecp } from '@noble/curves/secp256k1.js';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import os from 'os';
|
|
28
|
+
import { mkdirSync, writeFileSync, unlinkSync } from 'fs';
|
|
29
|
+
import { execSync, execFileSync } from 'child_process';
|
|
30
|
+
|
|
31
|
+
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
32
|
+
|
|
33
|
+
// axios adapter set in defaults.js — prevents undici "fetch failed" on Node.js v18+.
|
|
34
|
+
import { getDynamicRate } from '../config/index.js';
|
|
35
|
+
|
|
36
|
+
// ─── Node Status (v3: GET /) ──────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch node info from v3 node API.
|
|
40
|
+
* Returns a normalised object compatible with the rest of the codebase.
|
|
41
|
+
*/
|
|
42
|
+
export async function nodeStatusV3(remoteUrl, agent) {
|
|
43
|
+
const url = remoteUrl.replace(/\/+$/, '');
|
|
44
|
+
const before = Date.now();
|
|
45
|
+
const res = await axios.get(url + '/', { httpsAgent: agent || httpsAgent, timeout: 12_000 });
|
|
46
|
+
const after = Date.now();
|
|
47
|
+
const r = res.data?.result;
|
|
48
|
+
if (!r) throw new Error('No result in node status response');
|
|
49
|
+
|
|
50
|
+
// Detect server clock drift from the HTTP Date header.
|
|
51
|
+
// VMess AEAD auth fails if |client_time - server_time| > 120 seconds.
|
|
52
|
+
let clockDriftSec = null;
|
|
53
|
+
const dateHeader = res.headers?.['date'];
|
|
54
|
+
if (dateHeader) {
|
|
55
|
+
const serverTime = new Date(dateHeader).getTime();
|
|
56
|
+
if (!isNaN(serverTime)) {
|
|
57
|
+
const localMidpoint = before + (after - before) / 2;
|
|
58
|
+
clockDriftSec = Math.round((serverTime - localMidpoint) / 1000);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Normalise to match the shape the rest of server.js expects
|
|
63
|
+
return {
|
|
64
|
+
type: r.service_type === 'wireguard' ? 'wireguard' : 'v2ray',
|
|
65
|
+
moniker: r.moniker || '',
|
|
66
|
+
peers: r.peers || 0,
|
|
67
|
+
bandwidth: {
|
|
68
|
+
// downlink/uplink are bytes/s (string in v3)
|
|
69
|
+
download: parseInt(r.downlink || '0', 10),
|
|
70
|
+
upload: parseInt(r.uplink || '0', 10),
|
|
71
|
+
},
|
|
72
|
+
location: {
|
|
73
|
+
city: r.location?.city || '',
|
|
74
|
+
country: r.location?.country || '',
|
|
75
|
+
country_code: r.location?.country_code || '',
|
|
76
|
+
latitude: r.location?.latitude || 0,
|
|
77
|
+
longitude: r.location?.longitude || 0,
|
|
78
|
+
},
|
|
79
|
+
qos: { max_peers: r.qos?.max_peers || null },
|
|
80
|
+
clockDriftSec,
|
|
81
|
+
gigabyte_prices: [], // not in v3 status; fetched from LCD
|
|
82
|
+
_raw: r,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── WireGuard Key Generation ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Generate a WireGuard-compatible Curve25519 key pair.
|
|
90
|
+
* Returns { privateKey: Buffer(32), publicKey: Buffer(32) }
|
|
91
|
+
*/
|
|
92
|
+
export function generateWgKeyPair() {
|
|
93
|
+
// Generate private key with WireGuard bit clamping
|
|
94
|
+
const priv = Buffer.from(randomBytes(32));
|
|
95
|
+
priv[0] &= 248; // clear bottom 3 bits
|
|
96
|
+
priv[31] &= 127; // clear top bit
|
|
97
|
+
priv[31] |= 64; // set second-highest bit
|
|
98
|
+
|
|
99
|
+
// Derive public key via X25519 (Curve25519 scalar base mult)
|
|
100
|
+
const pub = Buffer.from(x25519.getPublicKey(priv));
|
|
101
|
+
|
|
102
|
+
return { privateKey: priv, publicKey: pub };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── v3 Handshake (POST /) ───────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Perform v3 node handshake.
|
|
109
|
+
* @param {string} remoteUrl - Node's HTTPS base URL
|
|
110
|
+
* @param {bigint} sessionId - Session ID (uint64)
|
|
111
|
+
* @param {Buffer} cosmosPrivKey - Raw secp256k1 private key bytes (32 bytes)
|
|
112
|
+
* @param {Buffer} wgPublicKey - WireGuard public key (32 bytes)
|
|
113
|
+
* @returns {{ assignedAddrs: string[], serverPubKey: string, serverEndpoints: string[] }}
|
|
114
|
+
*/
|
|
115
|
+
export async function initHandshakeV3(remoteUrl, sessionId, cosmosPrivKey, wgPublicKey, agent) {
|
|
116
|
+
// 1. Build peer request data
|
|
117
|
+
const peerRequest = { public_key: wgPublicKey.toString('base64') };
|
|
118
|
+
const dataBytes = Buffer.from(JSON.stringify(peerRequest));
|
|
119
|
+
|
|
120
|
+
// 2. Build message: BigEndian uint64 (8 bytes) ++ data
|
|
121
|
+
const idBuf = Buffer.alloc(8);
|
|
122
|
+
idBuf.writeBigUInt64BE(BigInt(sessionId));
|
|
123
|
+
const msg = Buffer.concat([idBuf, dataBytes]);
|
|
124
|
+
|
|
125
|
+
// 3. Sign: SHA256(msg) → secp256k1 compact 64-byte sig (r+s, no recovery byte) → base64
|
|
126
|
+
// IMPORTANT: Go's VerifySignature requires EXACTLY 64 bytes (len != 64 → false)
|
|
127
|
+
const msgHash = sha256(msg);
|
|
128
|
+
const sig = await Secp256k1.createSignature(msgHash, cosmosPrivKey);
|
|
129
|
+
// toFixedLength() returns 65 bytes (r+s+recovery) — take only first 64 (r+s)
|
|
130
|
+
const sigBytes = Buffer.from(sig.toFixedLength()).slice(0, 64);
|
|
131
|
+
const signature = sigBytes.toString('base64');
|
|
132
|
+
|
|
133
|
+
// 4. Encode Cosmos public key (compressed, 33 bytes): "secp256k1:<base64>"
|
|
134
|
+
const compressedPubKey = nobleSecp.getPublicKey(cosmosPrivKey, true); // true = compressed
|
|
135
|
+
const pubKeyEncoded = 'secp256k1:' + Buffer.from(compressedPubKey).toString('base64');
|
|
136
|
+
|
|
137
|
+
// 5. POST / with JSON body (Go []byte fields are base64 in JSON)
|
|
138
|
+
const idNum = Number(sessionId);
|
|
139
|
+
if (!Number.isSafeInteger(idNum)) throw new Error(`Session ID ${sessionId} exceeds safe integer range (max ${Number.MAX_SAFE_INTEGER})`);
|
|
140
|
+
const body = {
|
|
141
|
+
data: dataBytes.toString('base64'),
|
|
142
|
+
id: idNum,
|
|
143
|
+
pub_key: pubKeyEncoded,
|
|
144
|
+
signature: signature,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const url = remoteUrl.replace(/\/+$/, '') + '/';
|
|
148
|
+
let res;
|
|
149
|
+
try {
|
|
150
|
+
res = await axios.post(url, body, {
|
|
151
|
+
httpsAgent: agent || httpsAgent,
|
|
152
|
+
timeout: 90_000,
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const errData = err.response?.data;
|
|
157
|
+
const code = err.code || ''; // ECONNREFUSED, ETIMEDOUT, ENOTFOUND, etc.
|
|
158
|
+
const status = err.response?.status;
|
|
159
|
+
const detail = errData ? JSON.stringify(errData) : err.message;
|
|
160
|
+
throw new Error(`Node handshake failed (HTTP ${status}${code ? ', ' + code : ''}): ${detail}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const result = res.data?.result;
|
|
164
|
+
if (!result) {
|
|
165
|
+
const errInfo = res.data?.error;
|
|
166
|
+
throw new Error(`Node handshake error: ${JSON.stringify(errInfo || res.data)}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 6. Parse AddPeerResponse from result.data (base64-encoded JSON bytes)
|
|
170
|
+
const addPeerData = Buffer.from(result.data, 'base64').toString('utf8');
|
|
171
|
+
const addPeerResp = JSON.parse(addPeerData);
|
|
172
|
+
|
|
173
|
+
// result.addrs = node's WireGuard listening addresses (["IP:PORT", ...])
|
|
174
|
+
// addPeerResp.addrs = our assigned IPs (["10.x.x.x/24", ...])
|
|
175
|
+
// addPeerResp.metadata = [{port, public_key}, ...]
|
|
176
|
+
|
|
177
|
+
const metadata = (addPeerResp.metadata || [])[0] || {};
|
|
178
|
+
const serverPubKeyBase64 = metadata.public_key || '';
|
|
179
|
+
const serverPort = parseInt(metadata.port, 10) || 51820;
|
|
180
|
+
|
|
181
|
+
// Validate handshake response — garbage data from node → clear error instead of opaque WG failure
|
|
182
|
+
if (!serverPubKeyBase64) throw new Error('Handshake failed: node returned empty WireGuard public key');
|
|
183
|
+
if (serverPort < 1 || serverPort > 65535) throw new Error(`Handshake failed: invalid port ${serverPort} from node`);
|
|
184
|
+
|
|
185
|
+
const assignedAddrs = addPeerResp.addrs || [];
|
|
186
|
+
if (assignedAddrs.length === 0) throw new Error('Handshake failed: node returned no assigned addresses');
|
|
187
|
+
|
|
188
|
+
// Node's WireGuard endpoint: use first entry of result.addrs
|
|
189
|
+
// If it doesn't include a port, append the metadata port
|
|
190
|
+
const rawEndpoint = (result.addrs || [])[0] || '';
|
|
191
|
+
if (!rawEndpoint) throw new Error('Handshake failed: node returned no WireGuard endpoint addresses');
|
|
192
|
+
const serverEndpoint = rawEndpoint.includes(':')
|
|
193
|
+
? rawEndpoint
|
|
194
|
+
: `${rawEndpoint}:${serverPort}`;
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
assignedAddrs, // our IPs e.g. ["10.8.0.2/24"]
|
|
198
|
+
serverPubKey: serverPubKeyBase64, // server WG pub key (base64)
|
|
199
|
+
serverEndpoint, // "IP:PORT" for WireGuard Endpoint
|
|
200
|
+
serverEndpoints: result.addrs || [],
|
|
201
|
+
rawAddPeerResp: addPeerResp,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Build & Write WireGuard Config ──────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Write a WireGuard .conf file from v3 handshake result.
|
|
209
|
+
* @param {Buffer} wgPrivKey - Our WireGuard private key (32 bytes)
|
|
210
|
+
* @param {string[]} assignedAddrs - Our assigned IPs from node (e.g. ["10.8.0.2/24"])
|
|
211
|
+
* @param {string} serverPubKey - Server WireGuard public key (base64)
|
|
212
|
+
* @param {string} serverEndpoint - "IP:PORT" for the WireGuard server
|
|
213
|
+
* @param {string[]} [splitIPs] - If provided, only route these IPs through tunnel (split tunneling).
|
|
214
|
+
* Prevents internet death if tunnel cleanup fails.
|
|
215
|
+
* Pass null/empty for full tunnel (0.0.0.0/0) — NOT recommended for testing.
|
|
216
|
+
* @returns {string} Path to the written .conf file
|
|
217
|
+
*/
|
|
218
|
+
export function writeWgConfig(wgPrivKey, assignedAddrs, serverPubKey, serverEndpoint, splitIPs = null) {
|
|
219
|
+
// Use a SYSTEM-readable path on Windows. The WireGuard service runs as SYSTEM
|
|
220
|
+
// and often can't read configs from user temp dirs (C:\Users\X\AppData\Local\Temp).
|
|
221
|
+
// C:\ProgramData is readable by all accounts including SYSTEM.
|
|
222
|
+
const tmpDir = process.platform === 'win32'
|
|
223
|
+
? path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'sentinel-wg')
|
|
224
|
+
: path.join(os.tmpdir(), 'sentinel-wg');
|
|
225
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
226
|
+
|
|
227
|
+
// Restrict directory ACL so only current user + SYSTEM can access (ProgramData is world-readable by default)
|
|
228
|
+
if (process.platform === 'win32') {
|
|
229
|
+
try {
|
|
230
|
+
execFileSync('icacls', [tmpDir, '/inheritance:r', '/grant:r', `${process.env.USERNAME || 'BUILTIN\\Users'}:F`, '/grant:r', 'SYSTEM:F'], { stdio: 'pipe', timeout: 5000 });
|
|
231
|
+
} catch {} // non-fatal — WG still works, just less secure on multi-user systems
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const confPath = path.join(tmpDir, 'wgsent0.conf');
|
|
235
|
+
const privKeyBase64 = wgPrivKey.toString('base64');
|
|
236
|
+
const address = assignedAddrs.join(', ');
|
|
237
|
+
|
|
238
|
+
// Split tunneling: only route speedtest target IPs through tunnel.
|
|
239
|
+
// Full tunnel (0.0.0.0/0) captures ALL traffic — if tunnel dies, internet dies.
|
|
240
|
+
const useSplit = splitIPs && splitIPs.length > 0;
|
|
241
|
+
const allowedIPsStr = useSplit
|
|
242
|
+
? splitIPs.map(ip => ip.includes('/') ? ip : `${ip}/32`).join(', ')
|
|
243
|
+
: '0.0.0.0/0, ::/0';
|
|
244
|
+
|
|
245
|
+
const lines = [
|
|
246
|
+
'[Interface]',
|
|
247
|
+
`PrivateKey = ${privKeyBase64}`,
|
|
248
|
+
`Address = ${address}`,
|
|
249
|
+
`MTU = 1420`,
|
|
250
|
+
];
|
|
251
|
+
// Only set DNS for full tunnel; split tunnel uses system DNS (safer)
|
|
252
|
+
if (!useSplit) lines.push(`DNS = 208.67.222.222, 208.67.220.220`);
|
|
253
|
+
lines.push(
|
|
254
|
+
'',
|
|
255
|
+
'[Peer]',
|
|
256
|
+
`PublicKey = ${serverPubKey}`,
|
|
257
|
+
`Endpoint = ${serverEndpoint}`,
|
|
258
|
+
`AllowedIPs = ${allowedIPsStr}`,
|
|
259
|
+
`PersistentKeepalive = 30`,
|
|
260
|
+
'',
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const conf = lines.join('\n');
|
|
264
|
+
|
|
265
|
+
writeFileSync(confPath, conf, { encoding: 'utf8', mode: 0o600 }); // restrict: owner-only read/write
|
|
266
|
+
// On Windows, POSIX mode bits are ignored — must restrict via NTFS ACL.
|
|
267
|
+
// CRITICAL: This file contains the WireGuard private key. If icacls fails,
|
|
268
|
+
// the key is readable by all users on the machine.
|
|
269
|
+
if (process.platform === 'win32') {
|
|
270
|
+
try {
|
|
271
|
+
execFileSync('icacls', [confPath, '/inheritance:r', '/grant:r', `${process.env.USERNAME || 'BUILTIN\\Users'}:F`, '/grant:r', 'SYSTEM:F'], { stdio: 'pipe', timeout: 5000 });
|
|
272
|
+
} catch (aclErr) {
|
|
273
|
+
// Don't leave an unprotected private key on disk
|
|
274
|
+
try { unlinkSync(confPath); } catch {}
|
|
275
|
+
throw new Error(`Failed to secure WireGuard config (private key exposure risk): ${aclErr.message}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return confPath;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── V2Ray Handshake ──────────────────────────────────────────────────────────
|
|
282
|
+
// V2Ray peer request format: { "uuid": [byte_array] }
|
|
283
|
+
// We generate a UUID client-side and send it as an integer byte array.
|
|
284
|
+
|
|
285
|
+
import { randomUUID } from 'crypto';
|
|
286
|
+
|
|
287
|
+
// ─── Protobuf Encoder for v3 Messages ────────────────────────────────────────
|
|
288
|
+
// Manual encoding — avoids needing proto-generated code for v3 types.
|
|
289
|
+
// Field tag = (field_number << 3) | wire_type (0=varint, 2=length-delimited)
|
|
290
|
+
|
|
291
|
+
export function encodeVarint(value) {
|
|
292
|
+
let n = BigInt(value);
|
|
293
|
+
if (n < 0n) throw new RangeError('encodeVarint: negative values not supported (got ' + n + ')');
|
|
294
|
+
const bytes = [];
|
|
295
|
+
do {
|
|
296
|
+
let b = Number(n & 0x7fn);
|
|
297
|
+
n >>= 7n;
|
|
298
|
+
if (n > 0n) b |= 0x80;
|
|
299
|
+
bytes.push(b);
|
|
300
|
+
} while (n > 0n);
|
|
301
|
+
return Buffer.from(bytes);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function protoString(fieldNum, str) {
|
|
305
|
+
if (!str) return Buffer.alloc(0);
|
|
306
|
+
const b = Buffer.from(str, 'utf8');
|
|
307
|
+
return Buffer.concat([encodeVarint((BigInt(fieldNum) << 3n) | 2n), encodeVarint(b.length), b]);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function protoInt64(fieldNum, n) {
|
|
311
|
+
if (n === null || n === undefined) return Buffer.alloc(0);
|
|
312
|
+
return Buffer.concat([encodeVarint((BigInt(fieldNum) << 3n) | 0n), encodeVarint(n)]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function protoEmbedded(fieldNum, msgBytes) {
|
|
316
|
+
if (!msgBytes || msgBytes.length === 0) return Buffer.alloc(0);
|
|
317
|
+
return Buffer.concat([encodeVarint((BigInt(fieldNum) << 3n) | 2n), encodeVarint(msgBytes.length), msgBytes]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Convert sdk.Dec string to scaled big.Int string (multiply by 10^18).
|
|
322
|
+
* "0.003000000000000000" → "3000000000000000"
|
|
323
|
+
* "40152030" → "40152030000000000000000000" (only for sdk.Dec fields)
|
|
324
|
+
*/
|
|
325
|
+
export function decToScaledInt(decStr) {
|
|
326
|
+
if (decStr == null || decStr === '') return '0';
|
|
327
|
+
const s = String(decStr).trim();
|
|
328
|
+
if (!s || s === 'undefined' || s === 'null') return '0';
|
|
329
|
+
const dotIdx = s.indexOf('.');
|
|
330
|
+
if (dotIdx === -1) {
|
|
331
|
+
// Integer — multiply by 10^18
|
|
332
|
+
return s + '0'.repeat(18);
|
|
333
|
+
}
|
|
334
|
+
const intPart = s.slice(0, dotIdx);
|
|
335
|
+
const fracPart = s.slice(dotIdx + 1);
|
|
336
|
+
// Pad or trim fractional part to exactly 18 digits
|
|
337
|
+
const frac18 = (fracPart + '0'.repeat(18)).slice(0, 18);
|
|
338
|
+
const combined = (intPart === '' || intPart === '0' ? '' : intPart) + frac18;
|
|
339
|
+
// Remove leading zeros (but keep at least one digit)
|
|
340
|
+
const trimmed = combined.replace(/^0+/, '') || '0';
|
|
341
|
+
return trimmed;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Encode sentinel.types.v1.Price { denom, base_value, quote_value }
|
|
346
|
+
* base_value is sdk.Dec → encode as scaled big.Int string
|
|
347
|
+
* quote_value is sdk.Int → encode as integer string
|
|
348
|
+
*/
|
|
349
|
+
export function encodePrice({ denom, base_value, quote_value }) {
|
|
350
|
+
const baseValEncoded = decToScaledInt(String(base_value));
|
|
351
|
+
return Buffer.concat([
|
|
352
|
+
protoString(1, denom),
|
|
353
|
+
protoString(2, baseValEncoded),
|
|
354
|
+
protoString(3, String(quote_value)),
|
|
355
|
+
]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Encode sentinel.node.v3.MsgStartSessionRequest
|
|
360
|
+
* Replaces old nodeSubscribe + sessionStart (now one tx).
|
|
361
|
+
*
|
|
362
|
+
* Fields:
|
|
363
|
+
* 1: from (string) — account address
|
|
364
|
+
* 2: node_address (string) — node's sentnode1... address
|
|
365
|
+
* 3: gigabytes (int64)
|
|
366
|
+
* 4: hours (int64, 0 if using gigabytes)
|
|
367
|
+
* 5: max_price (Price, optional) — max price user will pay per GB
|
|
368
|
+
*/
|
|
369
|
+
export function encodeMsgStartSession({ from, node_address, gigabytes = 1, hours = 0, max_price }) {
|
|
370
|
+
return Uint8Array.from(Buffer.concat([
|
|
371
|
+
protoString(1, from),
|
|
372
|
+
protoString(2, node_address),
|
|
373
|
+
protoInt64(3, gigabytes),
|
|
374
|
+
hours ? protoInt64(4, hours) : Buffer.alloc(0),
|
|
375
|
+
max_price ? protoEmbedded(5, encodePrice(max_price)) : Buffer.alloc(0),
|
|
376
|
+
]));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* MsgStartSubscriptionRequest (sentinel.subscription.v3):
|
|
381
|
+
* 1: from (string)
|
|
382
|
+
* 2: id (uint64, plan ID)
|
|
383
|
+
* 3: denom (string, e.g. "udvpn")
|
|
384
|
+
* 4: renewal_price_policy (enum/int64, optional)
|
|
385
|
+
*/
|
|
386
|
+
export function encodeMsgStartSubscription({ from, id, denom = 'udvpn', renewalPricePolicy = 0 }) {
|
|
387
|
+
const parts = [
|
|
388
|
+
protoString(1, from),
|
|
389
|
+
protoInt64(2, id),
|
|
390
|
+
protoString(3, denom),
|
|
391
|
+
];
|
|
392
|
+
if (renewalPricePolicy) parts.push(protoInt64(4, renewalPricePolicy));
|
|
393
|
+
return Uint8Array.from(Buffer.concat(parts));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* MsgStartSessionRequest (sentinel.subscription.v3) — start session via subscription:
|
|
398
|
+
* 1: from (string)
|
|
399
|
+
* 2: id (uint64, subscription ID)
|
|
400
|
+
* 3: node_address (string)
|
|
401
|
+
*/
|
|
402
|
+
export function encodeMsgSubStartSession({ from, id, nodeAddress }) {
|
|
403
|
+
return Uint8Array.from(Buffer.concat([
|
|
404
|
+
protoString(1, from),
|
|
405
|
+
protoInt64(2, id),
|
|
406
|
+
protoString(3, nodeAddress),
|
|
407
|
+
]));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Extract session ID from MsgStartSession tx result.
|
|
412
|
+
* Checks ABCI events for sentinel.node.v3.EventCreateSession.session_id
|
|
413
|
+
*/
|
|
414
|
+
export function extractSessionId(txResult) {
|
|
415
|
+
// Try ABCI events first
|
|
416
|
+
for (const event of (txResult.events || [])) {
|
|
417
|
+
if (/session/i.test(event.type)) {
|
|
418
|
+
for (const attr of (event.attributes || [])) {
|
|
419
|
+
const k = typeof attr.key === 'string' ? attr.key
|
|
420
|
+
: Buffer.from(attr.key, 'base64').toString('utf8');
|
|
421
|
+
const v = typeof attr.value === 'string' ? attr.value
|
|
422
|
+
: Buffer.from(attr.value, 'base64').toString('utf8');
|
|
423
|
+
if (k === 'session_id' || k === 'SessionID' || k === 'id') {
|
|
424
|
+
const id = BigInt(v.replace(/"/g, ''));
|
|
425
|
+
if (id > 0n) return id;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Try rawLog
|
|
431
|
+
try {
|
|
432
|
+
const logs = JSON.parse(txResult.rawLog || '[]');
|
|
433
|
+
for (const log of (Array.isArray(logs) ? logs : [])) {
|
|
434
|
+
for (const ev of (log.events || [])) {
|
|
435
|
+
for (const attr of (ev.attributes || [])) {
|
|
436
|
+
if (attr.key === 'session_id' || attr.key === 'id') {
|
|
437
|
+
const id = BigInt(String(attr.value).replace(/"/g, ''));
|
|
438
|
+
if (id > 0n) return id;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} catch { }
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function generateV2RayUUID() {
|
|
448
|
+
return randomUUID();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Wait until a TCP port is accepting connections (SOCKS5 readiness probe).
|
|
453
|
+
* V2Ray takes variable time to bind its SOCKS5 inbound — a fixed sleep is unreliable.
|
|
454
|
+
* Returns true when ready, false if timeout.
|
|
455
|
+
* @param {number} port - Port to probe (e.g. SOCKS5 port)
|
|
456
|
+
* @param {number} timeoutMs - Max wait time (default: 10000)
|
|
457
|
+
* @param {number} intervalMs - Probe interval (default: 500)
|
|
458
|
+
*/
|
|
459
|
+
export async function waitForPort(port, timeoutMs = 10000, host = '127.0.0.1', intervalMs = 500) {
|
|
460
|
+
const deadline = Date.now() + timeoutMs;
|
|
461
|
+
while (Date.now() < deadline) {
|
|
462
|
+
const ok = await new Promise(resolve => {
|
|
463
|
+
const sock = net.createConnection({ host, port }, () => {
|
|
464
|
+
sock.destroy();
|
|
465
|
+
resolve(true);
|
|
466
|
+
});
|
|
467
|
+
sock.on('error', () => resolve(false));
|
|
468
|
+
sock.setTimeout(1000, () => { sock.destroy(); resolve(false); });
|
|
469
|
+
});
|
|
470
|
+
if (ok) return true;
|
|
471
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Build a complete V2Ray client JSON config from the node's handshake metadata.
|
|
478
|
+
*
|
|
479
|
+
* The node returns a metadata blob like:
|
|
480
|
+
* {"metadata":[{"port":"55215","proxy_protocol":2,"transport_protocol":3,"transport_security":1},...]}
|
|
481
|
+
*
|
|
482
|
+
* We must convert this into a proper V2Ray config with inbounds + outbounds.
|
|
483
|
+
*
|
|
484
|
+
* proxy_protocol: 1=VLess 2=VMess
|
|
485
|
+
* transport_protocol:1=domainsocket 2=gun 3=grpc 4=http 5=mkcp 6=quic 7=tcp 8=websocket
|
|
486
|
+
* transport_security:0=unspecified 1=none 2=TLS (per sentinel-go-sdk transport.go iota)
|
|
487
|
+
*
|
|
488
|
+
* @param {string} serverHost - Hostname of the node (e.g. "us04.quinz.top")
|
|
489
|
+
* @param {string} metadataJson - JSON string returned from handshake (hs.config)
|
|
490
|
+
* @param {string} uuid - UUID/UID we generated for the session
|
|
491
|
+
* @param {number} socksPort - Local SOCKS5 port to listen on (default 1080)
|
|
492
|
+
* @returns {object} - Complete V2Ray config object (call JSON.stringify to write)
|
|
493
|
+
*/
|
|
494
|
+
// ─── V2Ray Config Helpers ──────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
// Transport names — MUST match sentinel-go-sdk transport.go String() output exactly.
|
|
497
|
+
// CRITICAL: "gun" and "grpc" are DIFFERENT in V2Ray 5.x (gun = raw H2, grpc = gRPC lib).
|
|
498
|
+
const NETWORK_MAP = { 2: 'gun', 3: 'grpc', 4: 'http', 5: 'mkcp', 6: 'quic', 7: 'tcp', 8: 'websocket' };
|
|
499
|
+
|
|
500
|
+
// Sort by transport reliability so the first outbound (used by default routing) is most likely to work.
|
|
501
|
+
// Observed success rates from 780-node test (2026-03-09):
|
|
502
|
+
// tcp=100%, websocket=100%, http=100%, gun=100%, mkcp=100%
|
|
503
|
+
// grpc/none=87% (70/81), quic=0% (0/4), grpc/tls=0%
|
|
504
|
+
const TRANSPORT_PRIORITY = { 7: 0, 8: 1, 4: 2, 2: 3, 5: 4 }; // tcp, ws, http, gun, kcp
|
|
505
|
+
|
|
506
|
+
function transportSortKey(tp, ts) {
|
|
507
|
+
// Check dynamic rate first (in-memory, from actual runtime connections)
|
|
508
|
+
const network = NETWORK_MAP[tp] || 'tcp';
|
|
509
|
+
const key = ts === 2 ? `${network}/tls` : (tp === 3 ? 'grpc/none' : network);
|
|
510
|
+
const dynamicRate = getDynamicRate(key);
|
|
511
|
+
if (dynamicRate !== null) {
|
|
512
|
+
// Map rate [0,1] → sort key [0,10] (lower = better priority)
|
|
513
|
+
return Math.round((1 - dynamicRate) * 10);
|
|
514
|
+
}
|
|
515
|
+
// Fall back to hardcoded priority order
|
|
516
|
+
if (TRANSPORT_PRIORITY[tp] !== undefined) return TRANSPORT_PRIORITY[tp];
|
|
517
|
+
if (tp === 3 && ts !== 2) return 5; // grpc/none
|
|
518
|
+
if (tp === 3 && ts === 2) return 8; // grpc/tls
|
|
519
|
+
if (tp === 6 && ts === 2) return 9; // quic/tls
|
|
520
|
+
if (tp === 6 && ts !== 2) return 10; // quic/none
|
|
521
|
+
return 7;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Map v2-format metadata to v3 fields. Mutates entries in place. */
|
|
525
|
+
function normalizeV2Metadata(entries) {
|
|
526
|
+
const hasV2Format = entries.some(e => e.ca !== undefined || (e.protocol !== undefined && e.proxy_protocol === undefined));
|
|
527
|
+
if (!hasV2Format) return entries;
|
|
528
|
+
return entries.map(e => {
|
|
529
|
+
if (e.proxy_protocol !== undefined) return e; // already v3
|
|
530
|
+
const copy = { ...e };
|
|
531
|
+
// v2: protocol 1=VMess, 2=VLess → v3: proxy_protocol 2=VMess, 1=VLess (swapped)
|
|
532
|
+
if (copy.protocol !== undefined && copy.proxy_protocol === undefined) {
|
|
533
|
+
copy.proxy_protocol = copy.protocol === 2 ? 1 : 2;
|
|
534
|
+
}
|
|
535
|
+
if (copy.transport_protocol === undefined) copy.transport_protocol = 7; // default tcp
|
|
536
|
+
if (copy.transport_security === undefined) {
|
|
537
|
+
copy.transport_security = (copy.tls === 1 || copy.tls === true) ? 2 : 1;
|
|
538
|
+
}
|
|
539
|
+
return copy;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Filter out unsupported transports and sort by reliability. */
|
|
544
|
+
function filterAndSortTransports(entries) {
|
|
545
|
+
// Remove domainsocket (unix sockets — can't work remotely/on Windows)
|
|
546
|
+
const supported = entries.filter(e => e.transport_protocol !== 1);
|
|
547
|
+
if (supported.length === 0) throw new Error('No usable transport entries');
|
|
548
|
+
// grpc/tls now supported (serverName in tlsSettings fixes TLS SNI). Deprioritized in sort order.
|
|
549
|
+
const viable = supported;
|
|
550
|
+
return [...viable].sort((a, b) => {
|
|
551
|
+
return transportSortKey(a.transport_protocol, a.transport_security)
|
|
552
|
+
- transportSortKey(b.transport_protocol, b.transport_security);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Build a single V2Ray outbound from a metadata entry. */
|
|
557
|
+
function buildOutbound(entry, serverHost, uuid) {
|
|
558
|
+
const port = parseInt(entry.port, 10);
|
|
559
|
+
if (!port || port < 1 || port > 65535) return null;
|
|
560
|
+
const protocol = entry.proxy_protocol === 1 ? 'vless' : 'vmess';
|
|
561
|
+
const network = NETWORK_MAP[entry.transport_protocol] || 'tcp';
|
|
562
|
+
const security = entry.transport_security === 2 ? 'tls' : 'none';
|
|
563
|
+
const tag = `${serverHost}_${port}_${protocol}_${network}_${security}`;
|
|
564
|
+
|
|
565
|
+
const streamSettings = { network, security };
|
|
566
|
+
if (security === 'tls') streamSettings.tlsSettings = { allowInsecure: true, serverName: serverHost };
|
|
567
|
+
if (network === 'grpc' || network === 'gun') streamSettings.grpcSettings = { serviceName: '' };
|
|
568
|
+
if (network === 'quic') streamSettings.quicSettings = { security: 'none', key: '', header: { type: 'none' } };
|
|
569
|
+
|
|
570
|
+
const settings = protocol === 'vmess'
|
|
571
|
+
? { vnext: [{ address: serverHost, port, users: [{ id: uuid, alterId: 0 }] }] }
|
|
572
|
+
: { vnext: [{ address: serverHost, port, users: [{ id: uuid, encryption: 'none' }] }] };
|
|
573
|
+
|
|
574
|
+
return { tag, protocol, settings, streamSettings };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ─── Main Config Builder ──────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
export function buildV2RayClientConfig(serverHost, metadataJson, uuid, socksPort = 1080) {
|
|
580
|
+
const parsed = typeof metadataJson === 'string' ? JSON.parse(metadataJson) : metadataJson;
|
|
581
|
+
const entries = parsed.metadata || [];
|
|
582
|
+
|
|
583
|
+
if (entries.length === 0) throw new Error('No metadata entries in V2Ray handshake response');
|
|
584
|
+
|
|
585
|
+
const normalized = normalizeV2Metadata(entries);
|
|
586
|
+
const sorted = filterAndSortTransports(normalized);
|
|
587
|
+
const outbounds = sorted.map(e => buildOutbound(e, serverHost, uuid)).filter(Boolean);
|
|
588
|
+
if (outbounds.length === 0) throw new Error('All V2Ray outbounds filtered out (all ports invalid or transports unsupported)');
|
|
589
|
+
|
|
590
|
+
// Generate random SOCKS5 credentials — prevents other local processes from using the tunnel
|
|
591
|
+
const socksUser = randomBytes(8).toString('hex');
|
|
592
|
+
const socksPass = randomBytes(16).toString('hex');
|
|
593
|
+
|
|
594
|
+
// Match the official sentinel-go-sdk client.json.tmpl structure exactly:
|
|
595
|
+
// - API inbound (dokodemo-door) for StatsService
|
|
596
|
+
// - SOCKS inbound with sniffing
|
|
597
|
+
// - ALL metadata entries as separate outbounds
|
|
598
|
+
// - Routing: API → api tag, proxy → first outbound (most reliable transport)
|
|
599
|
+
// - NEVER use balancer/observatory — causes session poisoning (see known-issues.md)
|
|
600
|
+
// - Policy with uplinkOnly/downlinkOnly = 0
|
|
601
|
+
// - Global transport section with QUIC security=none (matches sentinel-go-sdk server)
|
|
602
|
+
// Random API port — avoids Windows TIME_WAIT collisions when v2ray is killed and respawned.
|
|
603
|
+
// Port 2080 (fixed) caused cascading bind failures across sequential node tests.
|
|
604
|
+
const apiPort = 10000 + Math.floor(Math.random() * 50000);
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
api: {
|
|
608
|
+
services: ['StatsService'],
|
|
609
|
+
tag: 'api',
|
|
610
|
+
},
|
|
611
|
+
inbounds: [
|
|
612
|
+
{
|
|
613
|
+
listen: '127.0.0.1',
|
|
614
|
+
port: apiPort,
|
|
615
|
+
protocol: 'dokodemo-door',
|
|
616
|
+
settings: { address: '127.0.0.1' },
|
|
617
|
+
tag: 'api',
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
listen: '127.0.0.1',
|
|
621
|
+
port: socksPort,
|
|
622
|
+
protocol: 'socks',
|
|
623
|
+
settings: {
|
|
624
|
+
auth: 'password',
|
|
625
|
+
accounts: [{ user: socksUser, pass: socksPass }],
|
|
626
|
+
ip: '127.0.0.1',
|
|
627
|
+
udp: true,
|
|
628
|
+
},
|
|
629
|
+
sniffing: { enabled: true, destOverride: ['http', 'tls'] },
|
|
630
|
+
tag: 'proxy',
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
log: { loglevel: 'info' },
|
|
634
|
+
outbounds,
|
|
635
|
+
routing: {
|
|
636
|
+
domainStrategy: 'IPIfNonMatch',
|
|
637
|
+
rules: [
|
|
638
|
+
{ inboundTag: ['api'], outboundTag: 'api', type: 'field' },
|
|
639
|
+
{ inboundTag: ['proxy'], outboundTag: outbounds[0].tag, type: 'field' },
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
policy: {
|
|
643
|
+
levels: { '0': { downlinkOnly: 0, uplinkOnly: 0 } },
|
|
644
|
+
system: { statsOutboundDownlink: true, statsOutboundUplink: true },
|
|
645
|
+
},
|
|
646
|
+
stats: {},
|
|
647
|
+
transport: {
|
|
648
|
+
dsSettings: {},
|
|
649
|
+
grpcSettings: {},
|
|
650
|
+
gunSettings: {},
|
|
651
|
+
httpSettings: {},
|
|
652
|
+
kcpSettings: {},
|
|
653
|
+
quicSettings: { security: 'none', key: '', header: { type: 'none' } },
|
|
654
|
+
tcpSettings: {},
|
|
655
|
+
wsSettings: {},
|
|
656
|
+
},
|
|
657
|
+
// SOCKS5 auth credentials — use these when creating SocksProxyAgent
|
|
658
|
+
_socksAuth: { user: socksUser, pass: socksPass },
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Perform v3 V2Ray handshake.
|
|
664
|
+
* Returns the V2Ray client config (JSON string in result.data).
|
|
665
|
+
*/
|
|
666
|
+
export async function initHandshakeV3V2Ray(remoteUrl, sessionId, cosmosPrivKey, uuid, agent) {
|
|
667
|
+
const hex = uuid.replace(/-/g, '');
|
|
668
|
+
const uuidBytes = [];
|
|
669
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
670
|
+
uuidBytes.push(parseInt(hex.substring(i, i + 2), 16));
|
|
671
|
+
}
|
|
672
|
+
const peerRequest = { uuid: uuidBytes };
|
|
673
|
+
const dataBytes = Buffer.from(JSON.stringify(peerRequest));
|
|
674
|
+
|
|
675
|
+
const idBuf = Buffer.alloc(8);
|
|
676
|
+
idBuf.writeBigUInt64BE(BigInt(sessionId));
|
|
677
|
+
const msg = Buffer.concat([idBuf, dataBytes]);
|
|
678
|
+
|
|
679
|
+
const msgHash = sha256(msg);
|
|
680
|
+
const sig = await Secp256k1.createSignature(msgHash, cosmosPrivKey);
|
|
681
|
+
const sigBytes = Buffer.from(sig.toFixedLength()).slice(0, 64); // 64 bytes only (r+s)
|
|
682
|
+
const signature = sigBytes.toString('base64');
|
|
683
|
+
const compressedPubKey = nobleSecp.getPublicKey(cosmosPrivKey, true);
|
|
684
|
+
const pubKeyEncoded = 'secp256k1:' + Buffer.from(compressedPubKey).toString('base64');
|
|
685
|
+
|
|
686
|
+
const v2IdNum = Number(sessionId);
|
|
687
|
+
if (!Number.isSafeInteger(v2IdNum)) throw new Error(`Session ID ${sessionId} exceeds safe integer range (max ${Number.MAX_SAFE_INTEGER})`);
|
|
688
|
+
const body = {
|
|
689
|
+
data: dataBytes.toString('base64'),
|
|
690
|
+
id: v2IdNum,
|
|
691
|
+
pub_key: pubKeyEncoded,
|
|
692
|
+
signature: signature,
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const url = remoteUrl.replace(/\/+$/, '') + '/';
|
|
696
|
+
let res;
|
|
697
|
+
try {
|
|
698
|
+
res = await axios.post(url, body, {
|
|
699
|
+
httpsAgent: agent || httpsAgent,
|
|
700
|
+
timeout: 90_000,
|
|
701
|
+
headers: { 'Content-Type': 'application/json' },
|
|
702
|
+
});
|
|
703
|
+
} catch (err) {
|
|
704
|
+
const errData = err.response?.data;
|
|
705
|
+
const code = err.code || '';
|
|
706
|
+
const status = err.response?.status;
|
|
707
|
+
const detail = errData ? JSON.stringify(errData) : err.message;
|
|
708
|
+
throw new Error(`V2Ray handshake failed (HTTP ${status}${code ? ', ' + code : ''}): ${detail}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const result = res.data?.result;
|
|
712
|
+
if (!result) {
|
|
713
|
+
throw new Error(`V2Ray handshake error: ${JSON.stringify(res.data?.error || res.data)}`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// result.data is base64-encoded V2Ray client config JSON
|
|
717
|
+
const v2rayConfig = Buffer.from(result.data, 'base64').toString('utf8');
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
config: v2rayConfig,
|
|
721
|
+
serverEndpoints: result.addrs || [],
|
|
722
|
+
};
|
|
723
|
+
}
|