blue-js-sdk 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/ai-path/ADMIN-ELEVATION.md +116 -0
  5. package/ai-path/AI-MANIFESTO.md +185 -0
  6. package/ai-path/BREAKING.md +74 -0
  7. package/ai-path/CHECKLIST.md +619 -0
  8. package/ai-path/CONNECTION-STEPS.md +724 -0
  9. package/ai-path/DECISION-TREE.md +378 -0
  10. package/ai-path/DEPENDENCIES.md +459 -0
  11. package/ai-path/E2E-FLOW.md +1555 -0
  12. package/ai-path/FAILURES.md +403 -0
  13. package/ai-path/GUIDE.md +1217 -0
  14. package/ai-path/README.md +558 -0
  15. package/ai-path/SPLIT-TUNNEL.md +266 -0
  16. package/ai-path/cli.js +535 -0
  17. package/ai-path/connect.js +884 -0
  18. package/ai-path/discover.js +178 -0
  19. package/ai-path/environment.js +266 -0
  20. package/ai-path/errors.js +86 -0
  21. package/ai-path/examples/autonomous-agent.mjs +220 -0
  22. package/ai-path/examples/multi-region.mjs +174 -0
  23. package/ai-path/examples/one-shot.mjs +31 -0
  24. package/ai-path/index.js +60 -0
  25. package/ai-path/pricing.js +136 -0
  26. package/ai-path/recommend.js +413 -0
  27. package/ai-path/run-admin.vbs +25 -0
  28. package/ai-path/setup.js +291 -0
  29. package/ai-path/wallet.js +137 -0
  30. package/app-helpers.js +363 -0
  31. package/app-settings.js +95 -0
  32. package/app-types.js +267 -0
  33. package/audit.js +847 -0
  34. package/batch.js +293 -0
  35. package/bin/setup.js +376 -0
  36. package/chain/authz.js +109 -0
  37. package/chain/broadcast.js +472 -0
  38. package/chain/client.js +160 -0
  39. package/chain/fee-grants.js +305 -0
  40. package/chain/index.js +891 -0
  41. package/chain/lcd.js +313 -0
  42. package/chain/queries.js +547 -0
  43. package/chain/rpc.js +408 -0
  44. package/chain/wallet.js +141 -0
  45. package/cli/config.js +143 -0
  46. package/cli/index.js +463 -0
  47. package/cli/output.js +182 -0
  48. package/cli.js +491 -0
  49. package/client/index.js +251 -0
  50. package/client.js +271 -0
  51. package/config/index.js +255 -0
  52. package/connection/connect.js +849 -0
  53. package/connection/disconnect.js +180 -0
  54. package/connection/discovery.js +321 -0
  55. package/connection/index.js +76 -0
  56. package/connection/proxy.js +148 -0
  57. package/connection/resilience.js +428 -0
  58. package/connection/security.js +232 -0
  59. package/connection/state.js +369 -0
  60. package/connection/tunnel.js +691 -0
  61. package/consumer.js +132 -0
  62. package/cosmjs-setup.js +1884 -0
  63. package/defaults.js +366 -0
  64. package/disk-cache.js +107 -0
  65. package/dist/client.d.ts +108 -0
  66. package/dist/client.d.ts.map +1 -0
  67. package/dist/client.js +400 -0
  68. package/dist/client.js.map +1 -0
  69. package/dist/index.d.ts +8 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +8 -0
  72. package/dist/index.js.map +1 -0
  73. package/errors/index.js +112 -0
  74. package/errors.js +218 -0
  75. package/examples/README.md +64 -0
  76. package/examples/connect-direct.mjs +106 -0
  77. package/examples/connect-plan.mjs +125 -0
  78. package/examples/error-handling.mjs +109 -0
  79. package/examples/query-nodes.mjs +94 -0
  80. package/examples/wallet-basics.mjs +61 -0
  81. package/generated/amino/amino.ts +9 -0
  82. package/generated/cosmos/base/v1beta1/coin.ts +365 -0
  83. package/generated/cosmos_proto/cosmos.ts +323 -0
  84. package/generated/gogoproto/gogo.ts +9 -0
  85. package/generated/google/protobuf/descriptor.ts +7601 -0
  86. package/generated/google/protobuf/duration.ts +208 -0
  87. package/generated/google/protobuf/timestamp.ts +238 -0
  88. package/generated/sentinel/lease/v1/events.ts +924 -0
  89. package/generated/sentinel/lease/v1/lease.ts +292 -0
  90. package/generated/sentinel/lease/v1/msg.ts +949 -0
  91. package/generated/sentinel/lease/v1/params.ts +164 -0
  92. package/generated/sentinel/node/v3/events.ts +881 -0
  93. package/generated/sentinel/node/v3/msg.ts +1002 -0
  94. package/generated/sentinel/node/v3/node.ts +263 -0
  95. package/generated/sentinel/node/v3/params.ts +183 -0
  96. package/generated/sentinel/plan/v3/events.ts +675 -0
  97. package/generated/sentinel/plan/v3/msg.ts +1191 -0
  98. package/generated/sentinel/plan/v3/plan.ts +283 -0
  99. package/generated/sentinel/provider/v2/events.ts +171 -0
  100. package/generated/sentinel/provider/v2/msg.ts +480 -0
  101. package/generated/sentinel/provider/v2/params.ts +131 -0
  102. package/generated/sentinel/provider/v2/provider.ts +246 -0
  103. package/generated/sentinel/session/v3/events.ts +480 -0
  104. package/generated/sentinel/session/v3/msg.ts +616 -0
  105. package/generated/sentinel/session/v3/params.ts +260 -0
  106. package/generated/sentinel/session/v3/proof.ts +180 -0
  107. package/generated/sentinel/session/v3/session.ts +384 -0
  108. package/generated/sentinel/subscription/v3/events.ts +1181 -0
  109. package/generated/sentinel/subscription/v3/msg.ts +1305 -0
  110. package/generated/sentinel/subscription/v3/params.ts +167 -0
  111. package/generated/sentinel/subscription/v3/subscription.ts +315 -0
  112. package/generated/sentinel/types/v1/bandwidth.ts +124 -0
  113. package/generated/sentinel/types/v1/price.ts +149 -0
  114. package/generated/sentinel/types/v1/renewal.ts +87 -0
  115. package/generated/sentinel/types/v1/status.ts +54 -0
  116. package/generated/typeRegistry.ts +27 -0
  117. package/index.js +486 -0
  118. package/node-connect.js +3015 -0
  119. package/operator.js +134 -0
  120. package/package.json +113 -0
  121. package/plan-operations.js +199 -0
  122. package/preflight.js +352 -0
  123. package/pricing/index.js +262 -0
  124. package/proto/amino/amino.proto +84 -0
  125. package/proto/cosmos/base/v1beta1/coin.proto +61 -0
  126. package/proto/cosmos_proto/cosmos.proto +112 -0
  127. package/proto/gogoproto/gogo.proto +145 -0
  128. package/proto/google/api/annotations.proto +31 -0
  129. package/proto/google/api/http.proto +370 -0
  130. package/proto/google/protobuf/any.proto +106 -0
  131. package/proto/google/protobuf/duration.proto +115 -0
  132. package/proto/google/protobuf/timestamp.proto +145 -0
  133. package/proto/sentinel/lease/v1/events.proto +52 -0
  134. package/proto/sentinel/lease/v1/genesis.proto +15 -0
  135. package/proto/sentinel/lease/v1/lease.proto +25 -0
  136. package/proto/sentinel/lease/v1/msg.proto +62 -0
  137. package/proto/sentinel/lease/v1/params.proto +17 -0
  138. package/proto/sentinel/node/v3/events.proto +50 -0
  139. package/proto/sentinel/node/v3/genesis.proto +15 -0
  140. package/proto/sentinel/node/v3/msg.proto +63 -0
  141. package/proto/sentinel/node/v3/node.proto +27 -0
  142. package/proto/sentinel/node/v3/params.proto +21 -0
  143. package/proto/sentinel/node/v3/querier.proto +63 -0
  144. package/proto/sentinel/plan/v3/events.proto +41 -0
  145. package/proto/sentinel/plan/v3/genesis.proto +21 -0
  146. package/proto/sentinel/plan/v3/msg.proto +83 -0
  147. package/proto/sentinel/plan/v3/plan.proto +32 -0
  148. package/proto/sentinel/plan/v3/querier.proto +53 -0
  149. package/proto/sentinel/provider/v2/events.proto +16 -0
  150. package/proto/sentinel/provider/v2/genesis.proto +15 -0
  151. package/proto/sentinel/provider/v2/msg.proto +35 -0
  152. package/proto/sentinel/provider/v2/params.proto +17 -0
  153. package/proto/sentinel/provider/v2/provider.proto +24 -0
  154. package/proto/sentinel/provider/v3/genesis.proto +15 -0
  155. package/proto/sentinel/provider/v3/params.proto +13 -0
  156. package/proto/sentinel/session/v3/events.proto +30 -0
  157. package/proto/sentinel/session/v3/genesis.proto +15 -0
  158. package/proto/sentinel/session/v3/msg.proto +50 -0
  159. package/proto/sentinel/session/v3/params.proto +25 -0
  160. package/proto/sentinel/session/v3/proof.proto +25 -0
  161. package/proto/sentinel/session/v3/querier.proto +100 -0
  162. package/proto/sentinel/session/v3/session.proto +50 -0
  163. package/proto/sentinel/subscription/v2/allocation.proto +21 -0
  164. package/proto/sentinel/subscription/v2/payout.proto +22 -0
  165. package/proto/sentinel/subscription/v3/events.proto +65 -0
  166. package/proto/sentinel/subscription/v3/genesis.proto +17 -0
  167. package/proto/sentinel/subscription/v3/msg.proto +83 -0
  168. package/proto/sentinel/subscription/v3/params.proto +21 -0
  169. package/proto/sentinel/subscription/v3/subscription.proto +33 -0
  170. package/proto/sentinel/types/v1/bandwidth.proto +19 -0
  171. package/proto/sentinel/types/v1/price.proto +21 -0
  172. package/proto/sentinel/types/v1/renewal.proto +21 -0
  173. package/proto/sentinel/types/v1/status.proto +16 -0
  174. package/protocol/encoding.js +341 -0
  175. package/protocol/events.js +361 -0
  176. package/protocol/handshake.js +297 -0
  177. package/protocol/index.js +15 -0
  178. package/protocol/messages.js +346 -0
  179. package/protocol/plans.js +199 -0
  180. package/protocol/v2ray.js +268 -0
  181. package/protocol/v3.js +723 -0
  182. package/protocol/wireguard.js +125 -0
  183. package/security/index.js +132 -0
  184. package/session-manager.js +329 -0
  185. package/session-tracker.js +80 -0
  186. package/setup.js +376 -0
  187. package/speedtest/index.js +528 -0
  188. package/speedtest.js +567 -0
  189. package/src/client.ts +502 -0
  190. package/src/index.ts +20 -0
  191. package/state/index.js +347 -0
  192. package/state.js +516 -0
  193. package/test-all-chain-ops.js +493 -0
  194. package/test-all-logic.js +199 -0
  195. package/test-all-msg-types.js +292 -0
  196. package/test-every-connection.js +208 -0
  197. package/test-feegrant-connect.js +98 -0
  198. package/test-logic.js +148 -0
  199. package/test-mainnet.js +176 -0
  200. package/test-plan-lifecycle.js +335 -0
  201. package/tls-trust.js +132 -0
  202. package/tsconfig.build.json +20 -0
  203. package/tsconfig.json +34 -0
  204. package/types/chain.d.ts +746 -0
  205. package/types/connection.d.ts +425 -0
  206. package/types/errors.d.ts +174 -0
  207. package/types/index.d.ts +1380 -0
  208. package/types/nodes.d.ts +187 -0
  209. package/types/pricing.d.ts +156 -0
  210. package/types/protocol.d.ts +332 -0
  211. package/types/session.d.ts +236 -0
  212. package/types/settings.d.ts +192 -0
  213. package/v3protocol.js +1053 -0
  214. package/wallet/index.js +153 -0
  215. package/wireguard.js +307 -0
package/state.js ADDED
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Sentinel dVPN SDK — Local State Persistence
3
+ *
4
+ * Tracks active sessions, V2Ray PIDs, and WireGuard tunnel names across process restarts.
5
+ * Also tracks session history to avoid reusing poisoned (failed handshake) sessions.
6
+ * State is saved to ~/.sentinel-sdk/state.json.
7
+ * Session history is saved to ~/.sentinel-sdk/sessions.json.
8
+ * PID file at ~/.sentinel-sdk/app.pid for server process management.
9
+ *
10
+ * When the process crashes mid-connection:
11
+ * - In-memory state (activeV2RayProc, activeWgTunnel) is lost
12
+ * - The tunnel/proxy may still be running (WG service, v2ray.exe, system proxy)
13
+ * - On next startup, loadState() + recoverOrphans() detects and cleans up
14
+ *
15
+ * Usage:
16
+ * import { saveState, loadState, clearState, recoverOrphans } from './state.js';
17
+ * import { markSessionPoisoned, isSessionPoisoned, getSessionHistory } from './state.js';
18
+ * import { writePidFile, checkPidFile, clearPidFile } from './state.js';
19
+ */
20
+
21
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'fs';
22
+ import { execSync, execFileSync } from 'child_process';
23
+ import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
24
+ import path from 'path';
25
+ import os from 'os';
26
+
27
+ // ── State file validation (prevents command injection via poisoned state.json) ──
28
+ const STATE_SCHEMA = {
29
+ sessionId: v => v == null || /^\d+$/.test(String(v)),
30
+ serviceType: v => v == null || v === 'wireguard' || v === 'v2ray',
31
+ v2rayPid: v => v == null || (Number.isInteger(Number(v)) && Number(v) > 0),
32
+ socksPort: v => v == null || (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= 65535),
33
+ wgTunnelName: v => v == null || /^[a-zA-Z0-9_-]{1,64}$/.test(v),
34
+ systemProxySet: v => v == null || typeof v === 'boolean',
35
+ killSwitchEnabled: v => v == null || typeof v === 'boolean',
36
+ nodeAddress: v => v == null || /^sentnode1[a-z0-9]{38}$/.test(v),
37
+ confPath: v => v == null || (typeof v === 'string' && v.length <= 260 && (/^[a-zA-Z]:[\\\/][a-zA-Z0-9_.\-\\\/ ]+$/.test(v) || /^\/[a-zA-Z0-9_.\-\/ ]+$/.test(v))),
38
+ };
39
+
40
+ function validateStateValues(state) {
41
+ for (const [field, validate] of Object.entries(STATE_SCHEMA)) {
42
+ if (state[field] !== undefined && !validate(state[field])) {
43
+ console.warn(`[sentinel-sdk] Corrupted state: invalid ${field} "${state[field]}" — skipping recovery`);
44
+ return false;
45
+ }
46
+ }
47
+ return true;
48
+ }
49
+
50
+ const STATE_DIR = path.join(os.homedir(), '.sentinel-sdk');
51
+ const STATE_FILE = path.join(STATE_DIR, 'state.json');
52
+ const SESSIONS_FILE = path.join(STATE_DIR, 'sessions.json');
53
+ const PID_FILE = path.join(STATE_DIR, 'app.pid');
54
+
55
+ /**
56
+ * Save current connection state to disk.
57
+ * Call this after a successful connection.
58
+ * @param {object} state
59
+ * @param {string} state.sessionId - Active session ID
60
+ * @param {string} state.serviceType - 'wireguard' | 'v2ray'
61
+ * @param {number} state.v2rayPid - V2Ray process PID (if v2ray)
62
+ * @param {number} state.socksPort - SOCKS5 port (if v2ray)
63
+ * @param {string} state.wgTunnelName - WireGuard tunnel service name (if wireguard)
64
+ * @param {boolean} state.systemProxySet - Whether Windows system proxy was set
65
+ * @param {string} state.nodeAddress - Connected node address
66
+ * @param {string} state.confPath - WireGuard config file path
67
+ */
68
+ export function saveState(state) {
69
+ try {
70
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
71
+ // Strip unknown fields — only persist STATE_SCHEMA keys + metadata
72
+ const ALLOWED_KEYS = new Set([...Object.keys(STATE_SCHEMA), 'savedAt', 'pid']);
73
+ const cleaned = {};
74
+ for (const [k, v] of Object.entries(state)) {
75
+ if (ALLOWED_KEYS.has(k)) cleaned[k] = v;
76
+ }
77
+ const data = {
78
+ ...cleaned,
79
+ savedAt: new Date().toISOString(),
80
+ pid: process.pid,
81
+ };
82
+ writeFileSync(STATE_FILE + '.tmp', JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
83
+ renameSync(STATE_FILE + '.tmp', STATE_FILE);
84
+ } catch (e) { console.warn('[sentinel-sdk] saveState warning:', e.message); }
85
+ }
86
+
87
+ /**
88
+ * Load saved state from disk.
89
+ * Returns null if no state file exists or it's corrupt.
90
+ */
91
+ export function loadState() {
92
+ try {
93
+ if (!existsSync(STATE_FILE)) return null;
94
+ return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Clear saved state (call after successful disconnect).
102
+ */
103
+ export function clearState() {
104
+ try {
105
+ if (existsSync(STATE_FILE)) unlinkSync(STATE_FILE);
106
+ } catch (e) { console.warn('[sentinel-sdk] clearState warning:', e.message); }
107
+ }
108
+
109
+ /**
110
+ * Detect and clean up orphaned tunnels/processes from a previous crash.
111
+ * Call this at app startup after registerCleanupHandlers().
112
+ *
113
+ * Returns what was cleaned up (for logging).
114
+ */
115
+ export function recoverOrphans() {
116
+ const state = loadState();
117
+ if (!state) return null;
118
+
119
+ // Validate state values before using them in shell commands (prevents command injection via poisoned state.json)
120
+ if (!validateStateValues(state)) {
121
+ clearState();
122
+ return { found: true, cleaned: ['Corrupted state file removed'] };
123
+ }
124
+
125
+ const recovered = { found: true, cleaned: [] };
126
+
127
+ // Check if the process that saved the state is still running
128
+ const savedPid = state.pid;
129
+ let processAlive = false;
130
+ if (savedPid) {
131
+ try {
132
+ process.kill(savedPid, 0); // signal 0 = check existence
133
+ processAlive = true;
134
+ } catch {
135
+ processAlive = false;
136
+ }
137
+ }
138
+
139
+ // If the original process is still running, don't touch anything
140
+ if (processAlive) {
141
+ return { found: true, cleaned: [], note: `Original process ${savedPid} still running` };
142
+ }
143
+
144
+ // Clean up orphaned V2Ray
145
+ if (state.serviceType === 'v2ray' && state.v2rayPid) {
146
+ try {
147
+ if (process.platform === 'win32') {
148
+ execFileSync('taskkill', ['/F', '/PID', String(state.v2rayPid)], { stdio: 'pipe', timeout: 5000 });
149
+ } else {
150
+ process.kill(state.v2rayPid, 'SIGKILL');
151
+ }
152
+ recovered.cleaned.push(`v2ray PID ${state.v2rayPid}`);
153
+ } catch {} // already dead — expected if process exited naturally
154
+ }
155
+
156
+ // Clean up orphaned WireGuard tunnel
157
+ if (state.serviceType === 'wireguard' && state.wgTunnelName) {
158
+ try {
159
+ if (process.platform === 'win32') {
160
+ // Check if WireGuard service exists
161
+ const out = execFileSync('sc', ['query', `WireGuardTunnel$${state.wgTunnelName}`], {
162
+ encoding: 'utf8', timeout: 5000, stdio: 'pipe',
163
+ });
164
+ if (out.includes('RUNNING') || out.includes('STOPPED')) {
165
+ // Find wireguard.exe
166
+ const wgExe = ['C:\\Program Files\\WireGuard\\wireguard.exe', 'C:\\Program Files (x86)\\WireGuard\\wireguard.exe']
167
+ .find(p => existsSync(p));
168
+ if (wgExe) {
169
+ execFileSync(wgExe, ['/uninstalltunnelservice', state.wgTunnelName], { timeout: 15000, stdio: 'pipe' });
170
+ recovered.cleaned.push(`WireGuard tunnel ${state.wgTunnelName}`);
171
+ }
172
+ }
173
+ }
174
+ } catch (e) { console.warn('[sentinel-sdk] WG orphan cleanup warning:', e.message); }
175
+
176
+ // Linux/macOS: use wg-quick to remove stale tunnel
177
+ if (process.platform !== 'win32') {
178
+ try {
179
+ execFileSync('wg-quick', ['down', state.wgTunnelName], { timeout: 10000, stdio: 'pipe' });
180
+ recovered.cleaned.push(`WireGuard tunnel ${state.wgTunnelName} (wg-quick down)`);
181
+ } catch (e) { console.warn('[sentinel-sdk] wg-quick down warning:', e.message); }
182
+ }
183
+ }
184
+
185
+ // Clean up orphaned system proxy
186
+ if (state.systemProxySet) {
187
+ try {
188
+ if (process.platform === 'win32') {
189
+ const REG = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
190
+ execFileSync('reg', ['add', REG, '/v', 'ProxyEnable', '/t', 'REG_DWORD', '/d', '0', '/f'], { stdio: 'pipe' });
191
+ execFileSync('reg', ['delete', REG, '/v', 'ProxyServer', '/f'], { stdio: 'pipe' });
192
+ recovered.cleaned.push('Windows system proxy');
193
+ } else if (process.platform === 'darwin') {
194
+ const services = execFileSync('networksetup', ['-listallnetworkservices'], { encoding: 'utf8', stdio: 'pipe' })
195
+ .split('\n').filter(s => s && !s.startsWith('*') && !s.startsWith('An asterisk'));
196
+ for (const svc of services) {
197
+ try { execFileSync('networksetup', ['-setsocksfirewallproxystate', svc, 'off'], { stdio: 'pipe' }); } catch {} // service may not have proxy enabled
198
+ }
199
+ recovered.cleaned.push('macOS system proxy');
200
+ } else {
201
+ execFileSync('gsettings', ['set', 'org.gnome.system.proxy', 'mode', 'none'], { stdio: 'pipe' });
202
+ recovered.cleaned.push('Linux system proxy (GNOME)');
203
+ }
204
+ } catch (e) { console.warn('[sentinel-sdk] proxy orphan cleanup warning:', e.message); }
205
+ }
206
+
207
+ // Clean up orphaned kill switch (firewall rules persist across reboots — permanent internet loss)
208
+ if (state.killSwitchEnabled) {
209
+ try {
210
+ if (process.platform === 'win32') {
211
+ const rules = [
212
+ 'SentinelVPN-Allow-Tunnel', 'SentinelVPN-Allow-WG-Endpoint',
213
+ 'SentinelVPN-Allow-Loopback', 'SentinelVPN-Allow-DHCP',
214
+ 'SentinelVPN-Allow-DNS-Tunnel', 'SentinelVPN-Block-IPv6',
215
+ ];
216
+ for (const rule of rules) {
217
+ try { execFileSync('netsh', ['advfirewall', 'firewall', 'delete', 'rule', `name=${rule}`], { stdio: 'pipe' }); } catch {} // rule may not exist
218
+ }
219
+ execFileSync('netsh', ['advfirewall', 'set', 'allprofiles', 'firewallpolicy', 'blockinbound,allowoutbound'], { stdio: 'pipe' });
220
+ } else if (process.platform === 'darwin') {
221
+ try { execFileSync('pfctl', ['-d'], { stdio: 'pipe' }); } catch {} // pf may already be disabled
222
+ try { unlinkSync('/tmp/sentinel-killswitch.conf'); } catch {} // file may not exist
223
+ } else {
224
+ let hasRules = true;
225
+ while (hasRules) {
226
+ try { execFileSync('iptables', ['-D', 'OUTPUT', '-m', 'comment', '--comment', 'sentinel-vpn', '-j', 'ACCEPT'], { stdio: 'pipe' }); } catch { hasRules = false; }
227
+ }
228
+ try { execFileSync('iptables', ['-D', 'OUTPUT', '-m', 'comment', '--comment', 'sentinel-vpn', '-j', 'DROP'], { stdio: 'pipe' }); } catch {} // rule may not exist
229
+ try { execFileSync('ip6tables', ['-D', 'OUTPUT', '-m', 'comment', '--comment', 'sentinel-vpn', '-j', 'DROP'], { stdio: 'pipe' }); } catch {} // rule may not exist
230
+ }
231
+ recovered.cleaned.push('Kill switch firewall rules');
232
+ } catch (e) { console.warn('[sentinel-sdk] kill switch orphan cleanup warning:', e.message); }
233
+ }
234
+
235
+ // Clean up stale config file
236
+ if (state.confPath && existsSync(state.confPath)) {
237
+ try { unlinkSync(state.confPath); } catch (e) { console.warn('[sentinel-sdk] conf cleanup warning:', e.message); }
238
+ }
239
+
240
+ clearState();
241
+ return recovered;
242
+ }
243
+
244
+ // ─── Session Tracking ────────────────────────────────────────────────────────
245
+
246
+ /**
247
+ * Load session history from disk.
248
+ * Returns { sessions: { [sessionId]: { status, nodeAddress, error?, timestamp } } }
249
+ */
250
+ function loadSessions() {
251
+ try {
252
+ if (!existsSync(SESSIONS_FILE)) return { sessions: {} };
253
+ return JSON.parse(readFileSync(SESSIONS_FILE, 'utf8'));
254
+ } catch {
255
+ return { sessions: {} };
256
+ }
257
+ }
258
+
259
+ function saveSessions(data) {
260
+ try {
261
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
262
+ const tmpFile = SESSIONS_FILE + '.tmp';
263
+ writeFileSync(tmpFile, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
264
+ renameSync(tmpFile, SESSIONS_FILE);
265
+ } catch {} // best-effort session tracking — non-fatal if write fails
266
+ }
267
+
268
+ /**
269
+ * Mark a session as poisoned (handshake failed).
270
+ * findExistingSession callers should skip poisoned sessions.
271
+ * @param {string} sessionId
272
+ * @param {string} nodeAddress
273
+ * @param {string} error - Why it was poisoned
274
+ */
275
+ export function markSessionPoisoned(sessionId, nodeAddress, error) {
276
+ const data = loadSessions();
277
+ data.sessions[String(sessionId)] = {
278
+ status: 'poisoned',
279
+ nodeAddress,
280
+ error: error?.substring(0, 200),
281
+ timestamp: new Date().toISOString(),
282
+ };
283
+ // Prune old entries (keep last 200)
284
+ const entries = Object.entries(data.sessions);
285
+ if (entries.length > 200) {
286
+ const sorted = entries.sort((a, b) => new Date(b[1].timestamp) - new Date(a[1].timestamp));
287
+ data.sessions = Object.fromEntries(sorted.slice(0, 200));
288
+ }
289
+ saveSessions(data);
290
+ }
291
+
292
+ /**
293
+ * Mark a session as successfully connected.
294
+ * @param {string} sessionId
295
+ * @param {string} nodeAddress
296
+ */
297
+ export function markSessionActive(sessionId, nodeAddress) {
298
+ const data = loadSessions();
299
+ data.sessions[String(sessionId)] = {
300
+ status: 'active',
301
+ nodeAddress,
302
+ timestamp: new Date().toISOString(),
303
+ };
304
+ saveSessions(data);
305
+ }
306
+
307
+ /**
308
+ * Check if a session was poisoned (handshake failed previously).
309
+ * @param {string} sessionId
310
+ * @returns {boolean}
311
+ */
312
+ export function isSessionPoisoned(sessionId) {
313
+ const data = loadSessions();
314
+ return data.sessions[String(sessionId)]?.status === 'poisoned';
315
+ }
316
+
317
+ /**
318
+ * Get full session history for debugging.
319
+ * @returns {{ [sessionId]: { status, nodeAddress, error?, timestamp } }}
320
+ */
321
+ export function getSessionHistory() {
322
+ return loadSessions().sessions;
323
+ }
324
+
325
+ /**
326
+ * Load poisoned session keys from disk.
327
+ * Returns an array of "nodeAddr:sessionId" strings.
328
+ * @returns {string[]}
329
+ */
330
+ export function loadPoisonedKeys() {
331
+ const data = loadSessions();
332
+ return Array.isArray(data.poisoned) ? data.poisoned : [];
333
+ }
334
+
335
+ /**
336
+ * Save poisoned session keys to disk.
337
+ * @param {string[]} keys - Array of "nodeAddr:sessionId" strings
338
+ */
339
+ export function savePoisonedKeys(keys) {
340
+ const data = loadSessions();
341
+ // Keep last 500 poisoned keys to prevent unbounded growth
342
+ data.poisoned = keys.length > 500 ? keys.slice(-500) : keys;
343
+ saveSessions(data);
344
+ }
345
+
346
+ // ─── PID File ────────────────────────────────────────────────────────────────
347
+
348
+ /**
349
+ * Write a PID file for the current process.
350
+ * Use at server startup to enable clean restarts.
351
+ * @param {string} [name='app'] - App name (creates ~/.sentinel-sdk/{name}.pid)
352
+ * @returns {{ pidFile: string }}
353
+ */
354
+ export function writePidFile(name = 'app') {
355
+ try {
356
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
357
+ const pidFile = path.join(STATE_DIR, `${name}.pid`);
358
+ writeFileSync(pidFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { encoding: 'utf8', mode: 0o600 });
359
+ return { pidFile };
360
+ } catch {
361
+ return { pidFile: null };
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Check if a previous instance is running from a PID file.
367
+ * Returns { running: boolean, pid?: number } so the caller can decide what to do.
368
+ * @param {string} [name='app'] - App name
369
+ */
370
+ export function checkPidFile(name = 'app') {
371
+ try {
372
+ const pidFile = path.join(STATE_DIR, `${name}.pid`);
373
+ if (!existsSync(pidFile)) return { running: false };
374
+ const data = JSON.parse(readFileSync(pidFile, 'utf8'));
375
+ const pid = data.pid;
376
+ try {
377
+ process.kill(pid, 0); // signal 0 = check existence
378
+ return { running: true, pid, startedAt: data.startedAt };
379
+ } catch {
380
+ // Process is dead — stale PID file
381
+ unlinkSync(pidFile);
382
+ return { running: false, stalePid: pid };
383
+ }
384
+ } catch {
385
+ return { running: false };
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Remove the PID file (call on clean shutdown).
391
+ * @param {string} [name='app'] - App name
392
+ */
393
+ export function clearPidFile(name = 'app') {
394
+ try {
395
+ const pidFile = path.join(STATE_DIR, `${name}.pid`);
396
+ if (existsSync(pidFile)) unlinkSync(pidFile);
397
+ } catch {} // best-effort cleanup — non-fatal
398
+ }
399
+
400
+ // ─── Credential Cache ────────────────────────────────────────────────────────
401
+ // ─── Credential Encryption ──────────────────────────────────────────────────
402
+ // Credentials contain WireGuard private keys and V2Ray UUIDs.
403
+ // Encrypted at rest with AES-256-GCM using a machine-local key derived from
404
+ // hostname + username + SDK install path. Not a replacement for OS keyring,
405
+ // but prevents trivial plaintext exposure if the file is copied off-machine.
406
+
407
+ const CRED_FILE = path.join(STATE_DIR, 'credentials.enc.json');
408
+ const CRED_FILE_LEGACY = path.join(STATE_DIR, 'credentials.json');
409
+
410
+ function _credKey() {
411
+ const material = `sentinel-sdk:${os.hostname()}:${os.userInfo().username}:${STATE_DIR}`;
412
+ return createHash('sha256').update(material).digest();
413
+ }
414
+
415
+ function _encrypt(plaintext) {
416
+ const key = _credKey();
417
+ const iv = randomBytes(12);
418
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
419
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
420
+ const tag = cipher.getAuthTag();
421
+ return JSON.stringify({
422
+ v: 1,
423
+ iv: iv.toString('base64'),
424
+ tag: tag.toString('base64'),
425
+ data: enc.toString('base64'),
426
+ });
427
+ }
428
+
429
+ function _decrypt(envelope) {
430
+ const { v, iv, tag, data } = JSON.parse(envelope);
431
+ if (v !== 1) throw new Error('Unknown credential encryption version');
432
+ const key = _credKey();
433
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
434
+ decipher.setAuthTag(Buffer.from(tag, 'base64'));
435
+ return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8');
436
+ }
437
+
438
+ /**
439
+ * Save handshake credentials for a node+session pair.
440
+ * @param {string} nodeAddress - sentnode1...
441
+ * @param {string} sessionId - chain session ID
442
+ * @param {object} credentials - { serviceType, wgPrivateKey?, wgServerPubKey?, wgAssignedAddrs?, wgServerEndpoint?, v2rayUuid?, v2rayConfig?, confPath? }
443
+ */
444
+ export function saveCredentials(nodeAddress, sessionId, credentials) {
445
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
446
+ const store = loadCredentialStore();
447
+ store[nodeAddress] = {
448
+ sessionId: String(sessionId),
449
+ ...credentials,
450
+ savedAt: new Date().toISOString(),
451
+ };
452
+ // Prune old entries (keep max 100)
453
+ const entries = Object.entries(store);
454
+ if (entries.length > 100) {
455
+ entries.sort((a, b) => new Date(b[1].savedAt) - new Date(a[1].savedAt));
456
+ const pruned = Object.fromEntries(entries.slice(0, 100));
457
+ _writeCredentialStore(pruned);
458
+ } else {
459
+ _writeCredentialStore(store);
460
+ }
461
+ }
462
+
463
+ function _writeCredentialStore(store) {
464
+ const plaintext = JSON.stringify(store, null, 2);
465
+ const encrypted = _encrypt(plaintext);
466
+ const tmpPath = CRED_FILE + '.tmp';
467
+ writeFileSync(tmpPath, encrypted, { mode: 0o600 });
468
+ renameSync(tmpPath, CRED_FILE);
469
+ // Remove legacy plaintext file if it exists
470
+ try { if (existsSync(CRED_FILE_LEGACY)) unlinkSync(CRED_FILE_LEGACY); } catch { /* best effort */ }
471
+ }
472
+
473
+ /**
474
+ * Load saved credentials for a node.
475
+ * @param {string} nodeAddress
476
+ * @returns {{ sessionId: string, serviceType: string, wgPrivateKey?: string, wgServerPubKey?: string, wgAssignedAddrs?: string[], wgServerEndpoint?: string, v2rayUuid?: string, v2rayConfig?: string, savedAt: string } | null}
477
+ */
478
+ export function loadCredentials(nodeAddress) {
479
+ const store = loadCredentialStore();
480
+ return store[nodeAddress] || null;
481
+ }
482
+
483
+ /** Clear saved credentials for a node. */
484
+ export function clearCredentials(nodeAddress) {
485
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
486
+ const store = loadCredentialStore();
487
+ delete store[nodeAddress];
488
+ _writeCredentialStore(store);
489
+ }
490
+
491
+ /** Clear all saved credentials. */
492
+ export function clearAllCredentials() {
493
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
494
+ _writeCredentialStore({});
495
+ }
496
+
497
+ function loadCredentialStore() {
498
+ try {
499
+ // Try encrypted file first
500
+ if (existsSync(CRED_FILE)) {
501
+ const raw = readFileSync(CRED_FILE, 'utf-8');
502
+ const decrypted = _decrypt(raw);
503
+ return JSON.parse(decrypted);
504
+ }
505
+ // Migrate legacy plaintext credentials
506
+ if (existsSync(CRED_FILE_LEGACY)) {
507
+ const store = JSON.parse(readFileSync(CRED_FILE_LEGACY, 'utf-8'));
508
+ _writeCredentialStore(store); // Re-save encrypted + delete legacy
509
+ return store;
510
+ }
511
+ return {};
512
+ } catch {
513
+ // Decryption failed (different machine, corrupted) — start fresh
514
+ return {};
515
+ }
516
+ }