wireshade 1.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/lib/client.js ADDED
@@ -0,0 +1,548 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const EventEmitter = require('events');
4
+
5
+ let binding;
6
+ try {
7
+ binding = require('../wireshade.node');
8
+ } catch (e) {
9
+ try {
10
+ binding = require('../wireshade.win32-x64-msvc.node');
11
+ } catch (e2) {
12
+ throw new Error('Could not load native binding: ' + e2.message);
13
+ }
14
+ }
15
+ const { WireShade } = binding;
16
+ const { WireShadeAgent } = require('./agent');
17
+ const { WireShadeServer } = require('./server');
18
+ const { readWireGuardConfig } = require('./config_parser');
19
+ const http = require('http');
20
+ const https = require('https');
21
+ const tls = require('tls');
22
+ const net = require('net');
23
+ const dns = require('dns');
24
+
25
+ /**
26
+ * Connection states
27
+ */
28
+ const ConnectionState = {
29
+ DISCONNECTED: 'disconnected',
30
+ CONNECTING: 'connecting',
31
+ CONNECTED: 'connected',
32
+ RECONNECTING: 'reconnecting'
33
+ };
34
+
35
+ class WireShadeClient extends EventEmitter {
36
+ /**
37
+ * @param {Object|string} configOrPath - Config object OR path to .conf file
38
+ * @param {Object} [options] - Additional options if using config path
39
+ */
40
+ constructor(configOrPath, options = {}) {
41
+ super();
42
+
43
+ let config = configOrPath;
44
+ if (typeof configOrPath === 'string') {
45
+ config = {
46
+ ...options,
47
+ wireguard: readWireGuardConfig(configOrPath)
48
+ };
49
+ }
50
+
51
+ this.config = config;
52
+ this.hosts = config.hosts || {};
53
+ this.agents = { http: null, https: null, tcp: null };
54
+ this.servers = [];
55
+ this.gw = null;
56
+
57
+ // Connection state
58
+ this.state = ConnectionState.DISCONNECTED;
59
+ this.reconnectAttempts = 0;
60
+ this.reconnectTimer = null;
61
+ this.healthCheckTimer = null;
62
+
63
+ // Reconnection config with defaults
64
+ this.reconnectConfig = {
65
+ enabled: config.reconnect?.enabled !== false,
66
+ maxAttempts: config.reconnect?.maxAttempts ?? 10,
67
+ delay: config.reconnect?.delay ?? 1000,
68
+ maxDelay: config.reconnect?.maxDelay ?? 30000,
69
+ backoffMultiplier: config.reconnect?.backoffMultiplier ?? 1.5,
70
+ healthCheckInterval: config.reconnect?.healthCheckInterval ?? 30000
71
+ };
72
+
73
+
74
+
75
+
76
+
77
+ // Support for property-style callbacks
78
+ if (config.onConnect) this.on('connect', config.onConnect);
79
+ if (config.onDisconnect) this.on('disconnect', config.onDisconnect);
80
+ if (config.onReconnect) this.on('reconnect', config.onReconnect);
81
+
82
+ // Pre-create wrappers (lazy or eager)
83
+ this._httpWrapper = this._wrapModule(http, () => this.getHttpAgent());
84
+ this._httpsWrapper = this._wrapModule(https, () => this.getHttpsAgent());
85
+ }
86
+
87
+ /**
88
+ * Access the `http` module wrapper that routes requests through VPN
89
+ */
90
+ get http() { return this._httpWrapper; }
91
+
92
+ /**
93
+ * Access the `https` module wrapper that routes requests through VPN
94
+ */
95
+ get https() { return this._httpsWrapper; }
96
+
97
+ set onConnect(cb) { this.on('connect', cb); }
98
+ set onDisconnect(cb) { this.on('disconnect', cb); }
99
+
100
+ log(msg, ...args) {
101
+ if (this.config.logging !== false) {
102
+ console.log(msg, ...args);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Start the VPN connection
108
+ * @returns {Promise<void>}
109
+ */
110
+ async start() {
111
+ return new Promise((resolve, reject) => {
112
+ // If already connected, resolve immediately
113
+ if (this.state === ConnectionState.CONNECTED) {
114
+ return resolve();
115
+ }
116
+
117
+ const onConnect = () => {
118
+ cleanup();
119
+ resolve();
120
+ };
121
+
122
+ const onError = (err) => {
123
+ cleanup();
124
+ reject(err);
125
+ };
126
+
127
+ const cleanup = () => {
128
+ this.removeListener('connect', onConnect);
129
+ this.removeListener('error', onError);
130
+ // Also remove the disconnect listener we might catch during startup?
131
+ // For simplicity, rely on error or connect.
132
+ };
133
+
134
+ this.once('connect', onConnect);
135
+ // We might also want to catch immediate startup errors
136
+ this.once('disconnect', (err) => {
137
+ if (this.state !== ConnectionState.CONNECTED) {
138
+ cleanup();
139
+ reject(err || new Error("Disconnected during startup"));
140
+ }
141
+ });
142
+
143
+ this._initNative();
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Internal: Initialize native binding
149
+ */
150
+ _initNative() {
151
+ this.state = ConnectionState.CONNECTING;
152
+ this.emit('stateChange', this.state);
153
+
154
+ try {
155
+ // Create new native instance
156
+ this.gw = new WireShade(
157
+ this.config.wireguard.privateKey,
158
+ this.config.wireguard.peerPublicKey,
159
+ this.config.wireguard.presharedKey || "",
160
+ this.config.wireguard.endpoint,
161
+ this.config.wireguard.sourceIp
162
+ );
163
+
164
+ // Initialize/Update TCP Agent
165
+ this.agents.tcp = new WireShadeAgent(this.gw, {
166
+ keepAlive: true,
167
+ logging: this.logging,
168
+ onConnectionError: (err) => this._handleConnectionError(err)
169
+ });
170
+
171
+ // Reset cached agents
172
+ this.agents.http = null;
173
+ this.agents.https = null;
174
+
175
+ // Simulate async handshake completion
176
+ // Native currently doesn't expose a "Handshake Complete" event,
177
+ // so we assume success if no error occurs quickly.
178
+ // Future improvement: Expose handshake state from Rust.
179
+ setTimeout(() => {
180
+ if (this.state === ConnectionState.CONNECTING) {
181
+ this._onConnected();
182
+ }
183
+ }, 1000); // Reduced to 1s for snappier feel
184
+
185
+ } catch (err) {
186
+ this.log('[WireShadeClient] Connection failed:', err.message);
187
+ this._handleConnectionError(err);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Called when connection is established
193
+ */
194
+ _onConnected() {
195
+ const wasReconnecting = this.state === ConnectionState.RECONNECTING;
196
+ this.state = ConnectionState.CONNECTED;
197
+ this.reconnectAttempts = 0;
198
+
199
+ this.emit('stateChange', this.state);
200
+ this.emit('connect');
201
+
202
+ if (wasReconnecting) {
203
+ this.log('[WireShadeClient] Reconnected successfully!');
204
+ this.emit('reconnect');
205
+ if (this.config.onReconnect) this.config.onReconnect();
206
+ } else {
207
+ this.log('[WireShadeClient] Connected!');
208
+ if (this.config.onConnect) this.config.onConnect();
209
+ }
210
+
211
+ // Start health check
212
+ this._startHealthCheck();
213
+ }
214
+
215
+ /**
216
+ * Handle connection errors
217
+ */
218
+ _handleConnectionError(err) {
219
+ this.log('[WireShadeClient] Connection error:', err?.message || err);
220
+
221
+ if (this.state === ConnectionState.DISCONNECTED) {
222
+ return; // Already closed
223
+ }
224
+
225
+ this.state = ConnectionState.DISCONNECTED;
226
+ this.emit('stateChange', this.state);
227
+ this.emit('disconnect', err);
228
+
229
+ if (this.config.onDisconnect) this.config.onDisconnect(err);
230
+
231
+ // Attempt reconnection if enabled
232
+ if (this.reconnectConfig.enabled) {
233
+ this._scheduleReconnect();
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Schedule a reconnection attempt
239
+ */
240
+ _scheduleReconnect() {
241
+ if (this.reconnectTimer) {
242
+ clearTimeout(this.reconnectTimer);
243
+ }
244
+
245
+ // Check max attempts
246
+ if (this.reconnectConfig.maxAttempts > 0 &&
247
+ this.reconnectAttempts >= this.reconnectConfig.maxAttempts) {
248
+ this.log('[WireShadeClient] Max reconnection attempts reached');
249
+ this.emit('reconnectFailed');
250
+ return;
251
+ }
252
+
253
+ // Calculate delay with exponential backoff
254
+ const delay = Math.min(
255
+ this.reconnectConfig.delay * Math.pow(this.reconnectConfig.backoffMultiplier, this.reconnectAttempts),
256
+ this.reconnectConfig.maxDelay
257
+ );
258
+
259
+ this.reconnectAttempts++;
260
+ this.state = ConnectionState.RECONNECTING;
261
+ this.emit('stateChange', this.state);
262
+
263
+ this.log(`[WireShadeClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.reconnectConfig.maxAttempts || '∞'})`);
264
+
265
+ this.reconnectTimer = setTimeout(() => {
266
+ this.emit('reconnecting', this.reconnectAttempts);
267
+ this._initNative();
268
+ }, delay);
269
+ }
270
+
271
+ /**
272
+ * Start periodic health checks
273
+ */
274
+ _startHealthCheck() {
275
+ this._stopHealthCheck();
276
+
277
+ if (this.reconnectConfig.healthCheckInterval > 0) {
278
+ this.healthCheckTimer = setInterval(() => {
279
+ this._performHealthCheck();
280
+ }, this.reconnectConfig.healthCheckInterval);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Stop health checks
286
+ */
287
+ _stopHealthCheck() {
288
+ if (this.healthCheckTimer) {
289
+ clearInterval(this.healthCheckTimer);
290
+ this.healthCheckTimer = null;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Perform a health check (attempt a simple operation)
296
+ */
297
+ async _performHealthCheck() {
298
+ // For now, we rely on the WireGuard keepalives
299
+ // Future: Could ping a known VPN host
300
+ this.emit('healthCheck');
301
+ }
302
+
303
+ /**
304
+ * Manually trigger reconnection
305
+ */
306
+ reconnect() {
307
+ this.log('[WireShadeClient] Manual reconnect triggered');
308
+ this.reconnectAttempts = 0;
309
+ this._stopHealthCheck();
310
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
311
+ this._initNative();
312
+ }
313
+
314
+ getHttpAgent() {
315
+ if (!this.agents.http) {
316
+ this.agents.http = new http.Agent({
317
+ keepAlive: true,
318
+ lookup: this._customLookup.bind(this)
319
+ });
320
+ this.agents.http.createConnection = (options, cb) => {
321
+ return this.agents.tcp.createConnection(options, cb);
322
+ };
323
+ }
324
+ return this.agents.http;
325
+ }
326
+
327
+ getHttpsAgent() {
328
+ if (!this.agents.https) {
329
+ this.agents.https = new https.Agent({
330
+ keepAlive: true,
331
+ lookup: this._customLookup.bind(this),
332
+ });
333
+
334
+ this.agents.https.createConnection = (options, cb) => {
335
+ const rawSocket = this.agents.tcp.createConnection(options);
336
+ const tlsOptions = {
337
+ ...options,
338
+ socket: rawSocket,
339
+ servername: options.hostname || options.host
340
+ };
341
+ return tls.connect(tlsOptions, cb);
342
+ };
343
+ }
344
+ return this.agents.https;
345
+ }
346
+
347
+ addHost(hostname, ip) {
348
+ this.hosts[hostname] = ip;
349
+ }
350
+
351
+ async forwardLocal(localPort, remoteHost, remotePort) {
352
+ return new Promise((resolve, reject) => {
353
+ const server = net.createServer((clientSocket) => {
354
+ const tunnelSocket = this.agents.tcp.createConnection({
355
+ host: remoteHost,
356
+ port: remotePort
357
+ });
358
+
359
+ clientSocket.pipe(tunnelSocket);
360
+ tunnelSocket.pipe(clientSocket);
361
+
362
+ const cleanup = () => {
363
+ clientSocket.destroy();
364
+ tunnelSocket.destroy();
365
+ };
366
+ clientSocket.on('error', cleanup);
367
+ tunnelSocket.on('error', cleanup);
368
+ clientSocket.on('close', cleanup);
369
+ tunnelSocket.on('close', cleanup);
370
+ });
371
+
372
+ server.listen(localPort, () => {
373
+ this.servers.push(server);
374
+ resolve(server);
375
+ });
376
+
377
+ server.on('error', reject);
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Listen on a VPN port and forward all traffic to a local destination (Reverse Port Forwarding).
383
+ * @param {number} vpnPort - The port to listen on inside the VPN.
384
+ * @param {string} targetHost - The local host to forward to (e.g., 'localhost').
385
+ * @param {number} targetPort - The local port to forward to.
386
+ * @returns {Promise<WireShadeServer>}
387
+ */
388
+ async forwardRemote(vpnPort, targetHost, targetPort) {
389
+ return this.listen(vpnPort, (vpnSocket) => {
390
+ const localSocket = net.connect(targetPort, targetHost, () => {
391
+ // Pipe data between VPN socket and Local socket
392
+ vpnSocket.pipe(localSocket);
393
+ localSocket.pipe(vpnSocket);
394
+ });
395
+
396
+ const cleanup = () => {
397
+ vpnSocket.destroy();
398
+ localSocket.destroy();
399
+ };
400
+
401
+ vpnSocket.on('error', cleanup);
402
+ localSocket.on('error', cleanup);
403
+ vpnSocket.on('close', cleanup);
404
+ localSocket.on('close', cleanup);
405
+ });
406
+ }
407
+
408
+ _customLookup(hostname, options, callback) {
409
+ if (this.hosts[hostname]) {
410
+ return callback(null, this.hosts[hostname], 4);
411
+ }
412
+ dns.lookup(hostname, options, callback);
413
+ }
414
+
415
+ /**
416
+ * Internal: Wrap http/https module to inject agent
417
+ */
418
+ _wrapModule(module, agentGetter) {
419
+ const wrapper = { ...module };
420
+
421
+ wrapper.request = (...args) => {
422
+ // Determine where options object is
423
+ let options = typeof args[0] === 'string' || args[0] instanceof URL
424
+ ? args[1]
425
+ : args[0];
426
+
427
+ // Handle case where options is actually callback (if valid usage) or missing
428
+ if (typeof options === 'function' || !options) {
429
+ options = {};
430
+ if (typeof args[0] === 'string' || args[0] instanceof URL) {
431
+ if (typeof args[1] === 'function') {
432
+ return module.request(args[0], { agent: agentGetter() }, args[1]);
433
+ } else if (!args[1]) {
434
+ return module.request(args[0], { agent: agentGetter() });
435
+ }
436
+ } else {
437
+ return module.request({ ...args[0], agent: agentGetter() }, args[1]);
438
+ }
439
+ }
440
+
441
+ // If we are here, options exists and is an object.
442
+ const newOptions = { ...options, agent: agentGetter() };
443
+
444
+ if (typeof args[0] === 'string' || args[0] instanceof URL) {
445
+ return module.request(args[0], newOptions, args[2]);
446
+ } else {
447
+ return module.request(newOptions, args[1]);
448
+ }
449
+ };
450
+
451
+ wrapper.get = (...args) => {
452
+ const req = wrapper.request(...args);
453
+ req.end();
454
+ return req;
455
+ };
456
+
457
+ return wrapper;
458
+ }
459
+
460
+ /**
461
+ * Start a TCP server listener on the VPN interface
462
+ * @param {number} port
463
+ * @param {Function} [onConnection] - (socket) => void
464
+ * @returns {Promise<GhostWireServer>}
465
+ */
466
+ async listen(port, onConnection) {
467
+ if (!this.gw) throw new Error("WireShade not initialized");
468
+
469
+ const server = new WireShadeServer(this.gw, { logging: this.logging });
470
+
471
+ if (onConnection) {
472
+ server.on('connection', onConnection);
473
+ }
474
+
475
+ await server.listen(port);
476
+ this.servers.push(server);
477
+ return server;
478
+ }
479
+
480
+ /**
481
+ * Perform an HTTP GET request
482
+ * @param {string} url
483
+ * @param {Object} [options]
484
+ * @returns {Promise<string>} Body content
485
+ */
486
+ async get(url, options = {}) {
487
+ return this.request(url, { ...options, method: 'GET' });
488
+ }
489
+
490
+ /**
491
+ * Perform an HTTP request
492
+ * @param {string} url
493
+ * @param {Object} [options]
494
+ * @returns {Promise<string>} Body content
495
+ */
496
+ request(urlStr, options = {}) {
497
+ return new Promise((resolve, reject) => {
498
+ const isHttps = urlStr.startsWith('https:');
499
+ const agent = isHttps ? this.getHttpsAgent() : this.getHttpAgent();
500
+ const mod = isHttps ? https : http;
501
+
502
+ const req = mod.request(urlStr, { ...options, agent }, (res) => {
503
+ let data = '';
504
+ res.on('data', c => data += c);
505
+ res.on('end', () => resolve(data));
506
+ });
507
+
508
+ req.on('error', reject);
509
+
510
+ if (options.body) {
511
+ req.write(options.body);
512
+ }
513
+ req.end();
514
+ });
515
+ }
516
+
517
+ /**
518
+ * Create a TCP connection through the tunnel
519
+ * @param {Object} options - { host, port }
520
+ * @returns {net.Socket}
521
+ */
522
+ connect(options, connectionListener) {
523
+ if (!this.agents.tcp) throw new Error("WireShade not initialized");
524
+ return this.agents.tcp.createConnection(options, connectionListener);
525
+ }
526
+
527
+ close() {
528
+ this.state = ConnectionState.DISCONNECTED;
529
+ this.reconnectConfig.enabled = false; // Prevent reconnection
530
+
531
+ this._stopHealthCheck();
532
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
533
+
534
+ if (this.agents.http) this.agents.http.destroy();
535
+ if (this.agents.https) this.agents.https.destroy();
536
+ if (this.agents.tcp) this.agents.tcp.destroy();
537
+ this.servers.forEach(s => s.close());
538
+
539
+ this.emit('stateChange', this.state);
540
+ this.emit('close');
541
+
542
+ if (this.config.onDisconnect) {
543
+ this.config.onDisconnect();
544
+ }
545
+ }
546
+ }
547
+
548
+ module.exports = { WireShadeClient, ConnectionState };
@@ -0,0 +1,77 @@
1
+ const fs = require('fs');
2
+
3
+ /**
4
+ * Parses a standard WireGuard configuration file content.
5
+ * @param {string} content - The content of the .conf file
6
+ * @returns {Object} Config object suitable for WireShade
7
+ */
8
+ function parseWireGuardConfig(content) {
9
+ const lines = content.split('\n');
10
+ const config = {
11
+ privateKey: '',
12
+ sourceIp: '',
13
+ peerPublicKey: '',
14
+ presharedKey: '',
15
+ endpoint: ''
16
+ };
17
+
18
+ let currentSection = '';
19
+
20
+ for (let line of lines) {
21
+ line = line.trim();
22
+ if (!line || line.startsWith('#')) continue;
23
+
24
+ if (line.startsWith('[') && line.endsWith(']')) {
25
+ currentSection = line.slice(1, -1).toLowerCase();
26
+ continue;
27
+ }
28
+
29
+ const [key, ...valueParts] = line.split('=');
30
+ if (!key || valueParts.length === 0) continue;
31
+
32
+ const normalizedKey = key.trim().toLowerCase();
33
+ const value = valueParts.join('=').trim();
34
+
35
+ if (currentSection === 'interface') {
36
+ if (normalizedKey === 'privatekey') {
37
+ config.privateKey = value;
38
+ } else if (normalizedKey === 'address') {
39
+ // Remove subnet mask (e.g., /32) if present
40
+ config.sourceIp = value.split('/')[0].trim();
41
+ }
42
+ } else if (currentSection === 'peer') {
43
+ if (normalizedKey === 'publickey') {
44
+ config.peerPublicKey = value;
45
+ } else if (normalizedKey === 'presharedkey') {
46
+ config.presharedKey = value;
47
+ } else if (normalizedKey === 'endpoint') {
48
+ config.endpoint = value;
49
+ }
50
+ }
51
+ }
52
+
53
+ if (!config.privateKey || !config.peerPublicKey || !config.endpoint) {
54
+ throw new Error('Invalid WireGuard config: Missing required fields (PrivateKey, PublicKey, or Endpoint)');
55
+ }
56
+
57
+ return config;
58
+ }
59
+
60
+ /**
61
+ * Reads and parses a WireGuard config file.
62
+ * @param {string} filePath - Path to the .conf file
63
+ * @returns {Object} Config object
64
+ */
65
+ function readWireGuardConfig(filePath) {
66
+ try {
67
+ const content = fs.readFileSync(filePath, 'utf8');
68
+ return parseWireGuardConfig(content);
69
+ } catch (err) {
70
+ throw new Error(`Failed to read config file: ${err.message}`);
71
+ }
72
+ }
73
+
74
+ module.exports = {
75
+ parseWireGuardConfig,
76
+ readWireGuardConfig
77
+ };