appium-ios-tuntap 0.1.4 → 0.1.6

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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.1.6](https://github.com/appium/appium-ios-tuntap/compare/v0.1.5...v0.1.6) (2026-04-11)
2
+
3
+ ### Code Refactoring
4
+
5
+ * refactor TUN/TAP native layer and harden TunTap TypeScript interface ([3fa00fe](https://github.com/appium/appium-ios-tuntap/commit/3fa00fe57a7cc44e6490d8e97d7defe2f782c0dc))
6
+
7
+ ## [0.1.5](https://github.com/appium/appium-ios-tuntap/compare/v0.1.4...v0.1.5) (2026-04-09)
8
+
9
+ ### Code Refactoring
10
+
11
+ * remove global signal handlers from library code ([6e267df](https://github.com/appium/appium-ios-tuntap/commit/6e267dffe1785acfcd57d431090e4cd4995bfa39))
12
+
1
13
  ## [0.1.4](https://github.com/appium/appium-ios-tuntap/compare/v0.1.3...v0.1.4) (2026-04-08)
2
14
 
3
15
  ### Bug Fixes
Binary file
package/build/config.gypi CHANGED
@@ -502,7 +502,7 @@
502
502
  "cache": "/Users/runner/.npm",
503
503
  "node_gyp": "/Users/runner/work/appium-ios-tuntap/appium-ios-tuntap/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js",
504
504
  "npm_version": "11.11.0",
505
- "userconfig": "/private/var/folders/tb/y368xp_x10s3ty1b_mtl5mxr0000gn/T/2da411b2b99c0b3a7cfaf49f9d25047d/.npmrc",
505
+ "userconfig": "/private/var/folders/tb/y368xp_x10s3ty1b_mtl5mxr0000gn/T/8df09367f00966ee4ef2275480f452a9/.npmrc",
506
506
  "init_module": "/Users/runner/.npm-init.js",
507
507
  "globalconfig": "/Users/runner/hostedtoolcache/node/24.14.1/arm64/etc/npmrc",
508
508
  "local_prefix": "/Users/runner/work/appium-ios-tuntap/appium-ios-tuntap",
package/lib/TunTap.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type PacketCallback = (data: Buffer) => void;
1
2
  export declare class TunTapError extends Error {
2
3
  code?: string | undefined;
3
4
  constructor(message: string, code?: string | undefined);
@@ -13,21 +14,34 @@ export declare class TunTapDeviceError extends TunTapError {
13
14
  */
14
15
  export declare class TunTap {
15
16
  private device;
16
- private isOpen;
17
- private isClosed;
18
- private cleanupHandlers;
17
+ private _isOpen;
18
+ private _isClosed;
19
+ private removeExitListener;
19
20
  constructor(name?: string);
21
+ get isOpen(): boolean;
22
+ get isClosed(): boolean;
23
+ /**
24
+ * Throws if the device is not in a usable state (not open or already closed).
25
+ */
26
+ private assertReady;
20
27
  open(): boolean;
21
28
  close(): boolean;
22
29
  read(maxSize?: number): Buffer;
23
30
  write(data: Buffer): number;
31
+ /**
32
+ * Start event-driven reading from the TUN device.
33
+ * The callback is invoked with each packet read from the device.
34
+ */
35
+ startPolling(callback: PacketCallback, bufferSize?: number): void;
24
36
  get name(): string;
25
37
  get fd(): number;
26
38
  configure(address: string, mtu?: number): Promise<void>;
39
+ private configureLinux;
27
40
  addRoute(destination: string): Promise<void>;
41
+ private addRouteLinux;
28
42
  removeRoute(destination: string): Promise<void>;
29
43
  /**
30
- * Get interface statistics
44
+ * Get interface statistics.
31
45
  */
32
46
  getStats(): Promise<{
33
47
  rxBytes: number;
@@ -37,4 +51,6 @@ export declare class TunTap {
37
51
  rxErrors: number;
38
52
  txErrors: number;
39
53
  }>;
54
+ private getStatsDarwin;
55
+ private getStatsLinux;
40
56
  }
package/lib/TunTap.js CHANGED
@@ -1,35 +1,16 @@
1
- import { createRequire } from 'node:module';
2
1
  import { execFile } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
3
  import { isIPv6 } from 'node:net';
4
4
  import { promisify } from 'node:util';
5
5
  import { log } from './logger.js';
6
6
  const require = createRequire(import.meta.url);
7
- const nativeTuntap = require('../build/Release/tuntap.node');
8
7
  const execFileAsync = promisify(execFile);
9
- /**
10
- * Validates that a string is a safe IPv6 route destination (address or prefix).
11
- * Rejects shell metacharacters to prevent injection even though execFile is safe.
12
- */
13
- function isValidIPv6Route(destination) {
14
- if (!destination || typeof destination !== 'string') {
15
- return false;
16
- }
17
- const parts = destination.split('/');
18
- if (parts.length > 2) {
19
- return false;
20
- }
21
- const [addr, prefixLen] = parts;
22
- if (!isIPv6(addr)) {
23
- return false;
24
- }
25
- if (prefixLen !== undefined) {
26
- const len = Number(prefixLen);
27
- if (!Number.isInteger(len) || len < 0 || len > 128) {
28
- return false;
29
- }
30
- }
31
- return true;
32
- }
8
+ const PLATFORM = process.platform;
9
+ const DEFAULT_READ_BUFFER_SIZE = 4096;
10
+ const MAX_BUFFER_SIZE = 0xFFFF; // 65535
11
+ const DEFAULT_MTU = 1500;
12
+ const MIN_MTU = 1280;
13
+ const nativeTuntap = require('../build/Release/tuntap.node');
33
14
  // Custom error types
34
15
  export class TunTapError extends Error {
35
16
  code;
@@ -51,73 +32,105 @@ export class TunTapDeviceError extends TunTapError {
51
32
  this.name = 'TunTapDeviceError';
52
33
  }
53
34
  }
35
+ /**
36
+ * Validates an IPv6 route destination (address with optional CIDR prefix).
37
+ */
38
+ function isValidIPv6Route(destination) {
39
+ const parts = destination.split('/');
40
+ if (parts.length > 2) {
41
+ return false;
42
+ }
43
+ if (parts.length === 2) {
44
+ const prefix = parseInt(parts[1], 10);
45
+ if (isNaN(prefix) || prefix < 0 || prefix > 128 || parts[1] !== String(prefix)) {
46
+ return false;
47
+ }
48
+ }
49
+ return isIPv6(parts[0]);
50
+ }
54
51
  /**
55
52
  * TUN/TAP device for IP tunneling
56
53
  */
57
54
  export class TunTap {
58
55
  device;
59
- isOpen;
60
- isClosed;
61
- cleanupHandlers = [];
56
+ _isOpen;
57
+ _isClosed;
58
+ removeExitListener = null;
62
59
  constructor(name = '') {
63
60
  this.device = new nativeTuntap.TunDevice(name);
64
- this.isOpen = false;
65
- this.isClosed = false;
66
- // Register cleanup on process exit
61
+ this._isOpen = false;
62
+ this._isClosed = false;
63
+ // Register cleanup on process exit only.
64
+ // Signal handling is the caller's responsibility — libraries should not
65
+ // install global signal handlers. The kernel cleans up the TUN fd on exit.
67
66
  const cleanup = () => {
68
- if (this.isOpen && !this.isClosed) {
67
+ if (this._isOpen && !this._isClosed) {
69
68
  try {
70
69
  this.close();
71
70
  }
72
71
  catch (err) {
73
- log.error('Error closing TUN device during cleanup:', err);
72
+ log.error('Error closing TUN device during cleanup:', err.message);
74
73
  }
75
74
  }
76
75
  };
77
76
  process.once('exit', cleanup);
78
- process.once('SIGINT', cleanup);
79
- process.once('SIGTERM', cleanup);
80
- this.cleanupHandlers.push(() => {
77
+ this.removeExitListener = () => {
81
78
  process.removeListener('exit', cleanup);
82
- process.removeListener('SIGINT', cleanup);
83
- process.removeListener('SIGTERM', cleanup);
84
- });
79
+ };
80
+ }
81
+ get isOpen() {
82
+ return this._isOpen;
83
+ }
84
+ get isClosed() {
85
+ return this._isClosed;
86
+ }
87
+ /**
88
+ * Throws if the device is not in a usable state (not open or already closed).
89
+ */
90
+ assertReady() {
91
+ if (!this._isOpen) {
92
+ throw new TunTapError('Device not open');
93
+ }
94
+ if (this._isClosed) {
95
+ throw new TunTapError('Device has been closed');
96
+ }
85
97
  }
86
98
  open() {
87
- if (this.isClosed) {
99
+ if (this._isClosed) {
88
100
  throw new TunTapError('Device has been closed and cannot be reopened');
89
101
  }
90
- if (!this.isOpen) {
102
+ if (!this._isOpen) {
91
103
  try {
92
- this.isOpen = this.device.open();
93
- if (!this.isOpen) {
104
+ this._isOpen = this.device.open();
105
+ if (!this._isOpen) {
94
106
  throw new TunTapDeviceError('Failed to open TUN device');
95
107
  }
96
108
  }
97
109
  catch (err) {
98
- // Re-throw with more specific error types
99
- if (err.message?.includes('Permission denied') || err.message?.includes('sudo')) {
100
- throw new TunTapPermissionError(err.message);
110
+ const message = err.message ?? '';
111
+ if (message.includes('Permission denied') || message.includes('sudo')) {
112
+ throw new TunTapPermissionError(message);
101
113
  }
102
- else if (err.message?.includes('not available') || err.message?.includes('does not exist')) {
103
- throw new TunTapDeviceError(err.message);
114
+ if (message.includes('not available') || message.includes('does not exist')) {
115
+ throw new TunTapDeviceError(message);
104
116
  }
105
117
  throw err;
106
118
  }
107
119
  }
108
- return this.isOpen;
120
+ return this._isOpen;
109
121
  }
110
122
  close() {
111
- if (!this.isClosed) {
123
+ if (this.removeExitListener) {
124
+ this.removeExitListener();
125
+ this.removeExitListener = null;
126
+ }
127
+ if (!this._isClosed) {
112
128
  try {
113
- if (this.isOpen) {
129
+ if (this._isOpen) {
114
130
  this.device.close();
115
- this.isOpen = false;
131
+ this._isOpen = false;
116
132
  }
117
- this.isClosed = true;
118
- // Run cleanup handlers
119
- this.cleanupHandlers.forEach((handler) => handler());
120
- this.cleanupHandlers = [];
133
+ this._isClosed = true;
121
134
  }
122
135
  catch (err) {
123
136
  throw new TunTapError(`Failed to close device: ${err.message}`);
@@ -125,15 +138,10 @@ export class TunTap {
125
138
  }
126
139
  return true;
127
140
  }
128
- read(maxSize = 4096) {
129
- if (!this.isOpen) {
130
- throw new TunTapError('Device not open');
131
- }
132
- if (this.isClosed) {
133
- throw new TunTapError('Device has been closed');
134
- }
135
- if (maxSize <= 0 || maxSize > 65536) {
136
- throw new RangeError('Read size must be between 1 and 65536 bytes');
141
+ read(maxSize = DEFAULT_READ_BUFFER_SIZE) {
142
+ this.assertReady();
143
+ if (maxSize <= 0 || maxSize > MAX_BUFFER_SIZE) {
144
+ throw new RangeError(`Read size must be between 1 and ${MAX_BUFFER_SIZE} bytes`);
137
145
  }
138
146
  try {
139
147
  return this.device.read(maxSize);
@@ -143,20 +151,15 @@ export class TunTap {
143
151
  }
144
152
  }
145
153
  write(data) {
146
- if (!this.isOpen) {
147
- throw new TunTapError('Device not open');
148
- }
149
- if (this.isClosed) {
150
- throw new TunTapError('Device has been closed');
151
- }
154
+ this.assertReady();
152
155
  if (!Buffer.isBuffer(data)) {
153
156
  throw new TypeError('Data must be a Buffer');
154
157
  }
155
158
  if (data.length === 0) {
156
159
  return 0;
157
160
  }
158
- if (data.length > 65536) {
159
- throw new RangeError('Write data too large (max 65536 bytes)');
161
+ if (data.length > MAX_BUFFER_SIZE) {
162
+ throw new RangeError(`Write data too large (max ${MAX_BUFFER_SIZE} bytes)`);
160
163
  }
161
164
  try {
162
165
  const result = this.device.write(data);
@@ -169,57 +172,44 @@ export class TunTap {
169
172
  throw new TunTapError(`Write failed: ${err.message}`);
170
173
  }
171
174
  }
175
+ /**
176
+ * Start event-driven reading from the TUN device.
177
+ * The callback is invoked with each packet read from the device.
178
+ */
179
+ startPolling(callback, bufferSize = MAX_BUFFER_SIZE) {
180
+ this.assertReady();
181
+ if (typeof callback !== 'function') {
182
+ throw new TypeError('Callback must be a function');
183
+ }
184
+ if (bufferSize <= 0 || bufferSize > MAX_BUFFER_SIZE) {
185
+ throw new RangeError(`Buffer size must be between 1 and ${MAX_BUFFER_SIZE} bytes`);
186
+ }
187
+ this.device.startPolling(callback, bufferSize);
188
+ }
172
189
  get name() {
173
190
  return this.device.getName();
174
191
  }
175
192
  get fd() {
176
193
  return this.device.getFd();
177
194
  }
178
- async configure(address, mtu = 1500) {
179
- if (!this.isOpen) {
180
- throw new TunTapError('Device not open');
181
- }
182
- if (this.isClosed) {
183
- throw new TunTapError('Device has been closed');
184
- }
195
+ async configure(address, mtu = DEFAULT_MTU) {
196
+ this.assertReady();
185
197
  if (!isIPv6(address)) {
186
198
  throw new TypeError('Invalid IPv6 address format');
187
199
  }
188
- // Validate MTU
189
- if (mtu < 1280 || mtu > 65535) {
190
- throw new RangeError('MTU must be between 1280 and 65535');
200
+ if (mtu < MIN_MTU || mtu > MAX_BUFFER_SIZE) {
201
+ throw new RangeError(`MTU must be between ${MIN_MTU} and ${MAX_BUFFER_SIZE}`);
191
202
  }
192
- const platform = process.platform;
193
203
  try {
194
- if (platform === 'darwin') {
204
+ if (PLATFORM === 'darwin') {
195
205
  await execFileAsync('sudo', ['ifconfig', this.name, 'inet6', address, 'prefixlen', '64', 'up']);
196
206
  await execFileAsync('sudo', ['ifconfig', this.name, 'mtu', String(mtu)]);
197
207
  }
198
- else if (platform === 'linux') {
199
- try {
200
- await execFileAsync('which', ['ip']);
201
- }
202
- catch {
203
- throw new TunTapError('The "ip" command is not available. Please install the iproute2 package (e.g., sudo apt install iproute2)');
204
- }
205
- try {
206
- await execFileAsync('sudo', ['ip', '-6', 'addr', 'add', `${address}/64`, 'dev', this.name]);
207
- await execFileAsync('sudo', ['ip', 'link', 'set', 'dev', this.name, 'up', 'mtu', String(mtu)]);
208
- }
209
- catch (err) {
210
- if (err.message.includes('Permission denied')) {
211
- throw new TunTapPermissionError(`Permission denied when configuring network interface. Make sure you have sudo privileges or run the application with sudo.`);
212
- }
213
- else if (err.message.includes('File exists')) {
214
- log.warn(`Address ${address} may already be configured on ${this.name}`);
215
- }
216
- else {
217
- throw err;
218
- }
219
- }
208
+ else if (PLATFORM === 'linux') {
209
+ await this.configureLinux(address, mtu);
220
210
  }
221
211
  else {
222
- throw new TunTapError(`Unsupported platform: ${platform}`);
212
+ throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
223
213
  }
224
214
  }
225
215
  catch (err) {
@@ -229,133 +219,159 @@ export class TunTap {
229
219
  throw new TunTapError(`Failed to configure TUN interface: ${err.message}`);
230
220
  }
231
221
  }
232
- async addRoute(destination) {
233
- if (!this.isOpen) {
234
- throw new TunTapError('Device not open');
222
+ async configureLinux(address, mtu) {
223
+ try {
224
+ await execFileAsync('which', ['ip']);
235
225
  }
236
- if (this.isClosed) {
237
- throw new TunTapError('Device has been closed');
226
+ catch {
227
+ throw new TunTapError('The "ip" command is not available. Please install iproute2 (e.g., sudo apt install iproute2)');
228
+ }
229
+ try {
230
+ await execFileAsync('sudo', ['ip', '-6', 'addr', 'add', `${address}/64`, 'dev', this.name]);
231
+ }
232
+ catch (err) {
233
+ const message = err.message;
234
+ if (message.includes('Permission denied')) {
235
+ throw new TunTapPermissionError('Permission denied when configuring network interface. Run with sudo.');
236
+ }
237
+ if (!message.includes('File exists')) {
238
+ throw err;
239
+ }
240
+ log.warn(`Address ${address} may already be configured on ${this.name}`);
241
+ }
242
+ await execFileAsync('sudo', ['ip', 'link', 'set', 'dev', this.name, 'up', 'mtu', String(mtu)]);
243
+ }
244
+ async addRoute(destination) {
245
+ this.assertReady();
246
+ if (!destination || typeof destination !== 'string') {
247
+ throw new TypeError('Destination must be a non-empty string');
238
248
  }
239
249
  if (!isValidIPv6Route(destination)) {
240
- throw new TypeError('Destination must be a valid IPv6 address or prefix (e.g. fd00::1/64)');
250
+ throw new TypeError('Destination must be a valid IPv6 address or CIDR (e.g., fd00::1/128)');
241
251
  }
242
- const platform = process.platform;
243
252
  try {
244
- if (platform === 'darwin') {
253
+ if (PLATFORM === 'darwin') {
245
254
  await execFileAsync('sudo', ['route', '-n', 'add', '-inet6', destination, '-interface', this.name]);
246
255
  }
247
- else if (platform === 'linux') {
248
- try {
249
- await execFileAsync('sudo', ['ip', '-6', 'route', 'add', destination, 'dev', this.name]);
250
- }
251
- catch (err) {
252
- if (err.message.includes('Permission denied')) {
253
- throw new TunTapPermissionError(`Permission denied when adding route. Make sure you have sudo privileges or run the application with sudo.`);
254
- }
255
- else if (err.message.includes('File exists')) {
256
- log.info(`Route to ${destination} already exists`);
257
- }
258
- else {
259
- throw err;
260
- }
261
- }
256
+ else if (PLATFORM === 'linux') {
257
+ await this.addRouteLinux(destination);
262
258
  }
263
259
  else {
264
- throw new TunTapError(`Unsupported platform: ${platform}`);
260
+ throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
265
261
  }
266
262
  }
267
263
  catch (err) {
268
264
  if (err instanceof TunTapError) {
269
265
  throw err;
270
266
  }
271
- if (!err.message.includes('Route to') && !err.message.includes('already exists')) {
272
- throw new TunTapError(`Failed to add route: ${err.message}`);
267
+ throw new TunTapError(`Failed to add route: ${err.message}`);
268
+ }
269
+ }
270
+ async addRouteLinux(destination) {
271
+ try {
272
+ await execFileAsync('sudo', ['ip', '-6', 'route', 'add', destination, 'dev', this.name]);
273
+ }
274
+ catch (err) {
275
+ const message = err.message;
276
+ if (message.includes('Permission denied')) {
277
+ throw new TunTapPermissionError('Permission denied when adding route. Run with sudo.');
273
278
  }
279
+ if (message.includes('File exists')) {
280
+ log.info(`Route to ${destination} already exists`);
281
+ return;
282
+ }
283
+ throw err;
274
284
  }
275
285
  }
276
286
  async removeRoute(destination) {
277
- if (!this.isOpen) {
278
- throw new TunTapError('Device not open');
287
+ this.assertReady();
288
+ if (!destination || typeof destination !== 'string') {
289
+ throw new TypeError('Destination must be a non-empty string');
279
290
  }
280
291
  if (!isValidIPv6Route(destination)) {
281
- throw new TypeError('Destination must be a valid IPv6 address or prefix (e.g. fd00::1/64)');
292
+ throw new TypeError('Destination must be a valid IPv6 address or CIDR');
282
293
  }
283
- const platform = process.platform;
284
294
  try {
285
- if (platform === 'darwin') {
295
+ if (PLATFORM === 'darwin') {
286
296
  await execFileAsync('sudo', ['route', '-n', 'delete', '-inet6', destination]);
287
297
  }
288
- else if (platform === 'linux') {
298
+ else if (PLATFORM === 'linux') {
289
299
  await execFileAsync('sudo', ['ip', '-6', 'route', 'del', destination, 'dev', this.name]);
290
300
  }
291
301
  else {
292
- throw new TunTapError(`Unsupported platform: ${platform}`);
302
+ throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
293
303
  }
294
304
  }
295
305
  catch (err) {
296
- if (!err.message.includes('not in table') && !err.message.includes('No such process')) {
297
- throw new TunTapError(`Failed to remove route: ${err.message}`);
306
+ const message = err.message;
307
+ if (message.includes('not in table') || message.includes('No such process')) {
308
+ return;
298
309
  }
310
+ throw new TunTapError(`Failed to remove route: ${message}`);
299
311
  }
300
312
  }
301
313
  /**
302
- * Get interface statistics
314
+ * Get interface statistics.
303
315
  */
304
316
  async getStats() {
305
- if (!this.isOpen) {
306
- throw new TunTapError('Device not open');
307
- }
308
- const platform = process.platform;
317
+ this.assertReady();
309
318
  try {
310
- if (platform === 'darwin') {
311
- const { stdout } = await execFileAsync('netstat', ['-I', this.name, '-b']);
312
- const lines = stdout.trim().split('\n');
313
- if (lines.length < 2) {
314
- throw new Error('Unexpected netstat output');
315
- }
316
- const stats = lines[1].split(/\s+/);
317
- return {
318
- rxPackets: parseInt(stats[4], 10) || 0,
319
- rxErrors: parseInt(stats[5], 10) || 0,
320
- rxBytes: parseInt(stats[6], 10) || 0,
321
- txPackets: parseInt(stats[7], 10) || 0,
322
- txErrors: parseInt(stats[8], 10) || 0,
323
- txBytes: parseInt(stats[9], 10) || 0,
324
- };
319
+ if (PLATFORM === 'darwin') {
320
+ return await this.getStatsDarwin();
325
321
  }
326
- else if (platform === 'linux') {
327
- const { stdout } = await execFileAsync('ip', ['-s', 'link', 'show', this.name]);
328
- const lines = stdout.trim().split('\n');
329
- let rxIndex = -1;
330
- let txIndex = -1;
331
- for (let i = 0; i < lines.length; i++) {
332
- if (lines[i].includes('RX:')) {
333
- rxIndex = i + 1;
334
- }
335
- if (lines[i].includes('TX:')) {
336
- txIndex = i + 1;
337
- }
338
- }
339
- if (rxIndex === -1 || txIndex === -1) {
340
- throw new Error('Could not parse interface statistics');
341
- }
342
- const rxStats = lines[rxIndex].trim().split(/\s+/);
343
- const txStats = lines[txIndex].trim().split(/\s+/);
344
- return {
345
- rxBytes: parseInt(rxStats[0], 10) || 0,
346
- rxPackets: parseInt(rxStats[1], 10) || 0,
347
- rxErrors: parseInt(rxStats[2], 10) || 0,
348
- txBytes: parseInt(txStats[0], 10) || 0,
349
- txPackets: parseInt(txStats[1], 10) || 0,
350
- txErrors: parseInt(txStats[2], 10) || 0,
351
- };
352
- }
353
- else {
354
- throw new TunTapError(`Unsupported platform: ${platform}`);
322
+ if (PLATFORM === 'linux') {
323
+ return await this.getStatsLinux();
355
324
  }
325
+ throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
356
326
  }
357
327
  catch (err) {
328
+ if (err instanceof TunTapError) {
329
+ throw err;
330
+ }
358
331
  throw new TunTapError(`Failed to get interface statistics: ${err.message}`);
359
332
  }
360
333
  }
334
+ async getStatsDarwin() {
335
+ const { stdout } = await execFileAsync('netstat', ['-I', this.name, '-b']);
336
+ const lines = stdout.trim().split('\n');
337
+ if (lines.length < 2) {
338
+ throw new TunTapError('Unexpected netstat output');
339
+ }
340
+ const stats = lines[1].split(/\s+/);
341
+ return {
342
+ rxPackets: parseInt(stats[4], 10) || 0,
343
+ rxErrors: parseInt(stats[5], 10) || 0,
344
+ rxBytes: parseInt(stats[6], 10) || 0,
345
+ txPackets: parseInt(stats[7], 10) || 0,
346
+ txErrors: parseInt(stats[8], 10) || 0,
347
+ txBytes: parseInt(stats[9], 10) || 0,
348
+ };
349
+ }
350
+ async getStatsLinux() {
351
+ const { stdout } = await execFileAsync('ip', ['-s', 'link', 'show', this.name]);
352
+ const lines = stdout.trim().split('\n');
353
+ const rxIndex = lines.findIndex((line) => line.includes('RX:'));
354
+ const txIndex = lines.findIndex((line) => line.includes('TX:'));
355
+ if (rxIndex === -1 || txIndex === -1) {
356
+ throw new TunTapError('Could not parse interface statistics');
357
+ }
358
+ const rxLine = lines[rxIndex + 1]?.trim();
359
+ const txLine = lines[txIndex + 1]?.trim();
360
+ if (!rxLine || !txLine) {
361
+ throw new TunTapError('Could not parse interface statistics: missing data lines');
362
+ }
363
+ const rxStats = rxLine.split(/\s+/);
364
+ const txStats = txLine.split(/\s+/);
365
+ if (rxStats.length < 3 || txStats.length < 3) {
366
+ throw new TunTapError('Could not parse interface statistics: unexpected format');
367
+ }
368
+ return {
369
+ rxBytes: parseInt(rxStats[0], 10) || 0,
370
+ rxPackets: parseInt(rxStats[1], 10) || 0,
371
+ rxErrors: parseInt(rxStats[2], 10) || 0,
372
+ txBytes: parseInt(txStats[0], 10) || 0,
373
+ txPackets: parseInt(txStats[1], 10) || 0,
374
+ txErrors: parseInt(txStats[2], 10) || 0,
375
+ };
376
+ }
361
377
  }
package/lib/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { TunTap } from './TunTap.js';
1
+ export { TunTap, type PacketCallback } from './TunTap.js';
2
2
  export * from './tunnel.js';
package/lib/tunnel.js CHANGED
@@ -2,44 +2,6 @@ import { log } from './logger.js';
2
2
  import { TunTap } from './TunTap.js';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import { Buffer } from 'node:buffer';
5
- // Global registry for active tunnel managers
6
- const activeTunnelManagers = new Set();
7
- // Setup process signal handlers
8
- let signalHandlersSetup = false;
9
- function setupSignalHandlers() {
10
- if (signalHandlersSetup) {
11
- return;
12
- }
13
- signalHandlersSetup = true;
14
- const gracefulShutdown = async (signal) => {
15
- log.debug(`Received ${signal}, initiating graceful shutdown...`);
16
- // Copy the set to avoid modification during iteration
17
- const managers = Array.from(activeTunnelManagers);
18
- // Stop all tunnel managers
19
- await Promise.all(managers.map((manager) => {
20
- try {
21
- return manager.stop();
22
- }
23
- catch (err) {
24
- log.error('Error stopping tunnel manager:', err);
25
- }
26
- }));
27
- log.debug('All tunnel managers stopped, exiting...');
28
- process.exit(0);
29
- };
30
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
31
- process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
32
- // Handle uncaught exceptions
33
- process.on('uncaughtException', async (err) => {
34
- log.error('Uncaught exception:', err);
35
- await gracefulShutdown('uncaughtException');
36
- process.exit(1);
37
- });
38
- // Handle unhandled promise rejections
39
- process.on('unhandledRejection', (reason, promise) => {
40
- log.error(`Unhandled rejection at: ${promise} reason: ${reason}`);
41
- });
42
- }
43
5
  export class TunnelManager extends EventEmitter {
44
6
  tun;
45
7
  cancelled;
@@ -59,10 +21,6 @@ export class TunnelManager extends EventEmitter {
59
21
  this.packetQueue = [];
60
22
  this.deviceConn = null;
61
23
  this.cleanupPromise = null;
62
- // Setup signal handlers on first tunnel manager creation
63
- setupSignalHandlers();
64
- // Register this manager
65
- activeTunnelManagers.add(this);
66
24
  }
67
25
  addPacketConsumer(consumer) {
68
26
  this.packetConsumers.add(consumer);
@@ -364,8 +322,6 @@ export class TunnelManager extends EventEmitter {
364
322
  }
365
323
  this.tun = null;
366
324
  }
367
- // Unregister from active managers
368
- activeTunnelManagers.delete(this);
369
325
  log.debug(`Tunnel for ${tunName} closed successfully`);
370
326
  }
371
327
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-tuntap",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Native TUN/TAP interface module for Node.js",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
package/src/tuntap.cc CHANGED
@@ -9,7 +9,6 @@
9
9
  #include <memory>
10
10
  #include <mutex>
11
11
  #include <atomic>
12
- #include <csignal>
13
12
  #include <uv.h>
14
13
 
15
14
  #ifdef __APPLE__
@@ -20,17 +19,14 @@
20
19
  #include <netinet/in.h>
21
20
  #include <netinet6/in6_var.h>
22
21
  #define UTUN_CONTROL_NAME "com.apple.net.utun_control"
22
+ #define UTUN_HEADER_SIZE 4
23
23
  #else
24
24
  #include <linux/if.h>
25
25
  #include <linux/if_tun.h>
26
26
  #include <sys/stat.h>
27
+ #define UTUN_HEADER_SIZE 0
27
28
  #endif
28
29
 
29
- // Global state for signal handling
30
- static std::atomic<bool> g_shutdown_requested(false);
31
- static std::mutex g_devices_mutex;
32
- static std::vector<class TunDevice*> g_active_devices;
33
-
34
30
  // RAII wrapper for file descriptors
35
31
  class FileDescriptor {
36
32
  private:
@@ -46,11 +42,9 @@ public:
46
42
  }
47
43
  }
48
44
 
49
- // Disable copy
50
45
  FileDescriptor(const FileDescriptor&) = delete;
51
46
  FileDescriptor& operator=(const FileDescriptor&) = delete;
52
47
 
53
- // Enable move
54
48
  FileDescriptor(FileDescriptor&& other) noexcept : fd_(other.fd_) {
55
49
  other.fd_ = -1;
56
50
  }
@@ -90,14 +84,11 @@ public:
90
84
  TunDevice(const Napi::CallbackInfo& info);
91
85
  ~TunDevice();
92
86
 
93
- private:
94
- static Napi::FunctionReference constructor;
95
- static std::once_flag signal_handler_flag;
96
-
97
- public:
98
87
  void CloseInternal();
99
88
 
100
89
  private:
90
+ static Napi::FunctionReference constructor;
91
+
101
92
  Napi::Value Open(const Napi::CallbackInfo& info);
102
93
  Napi::Value Close(const Napi::CallbackInfo& info);
103
94
  Napi::Value Read(const Napi::CallbackInfo& info);
@@ -113,9 +104,9 @@ private:
113
104
 
114
105
  uv_poll_t* poll_handle_ = nullptr;
115
106
  Napi::ThreadSafeFunction tsfn_;
107
+ static constexpr size_t MAX_POLL_BUFFER = 65535;
108
+ size_t poll_buffer_size_ = MAX_POLL_BUFFER;
116
109
 
117
- void RegisterDevice();
118
- void UnregisterDevice();
119
110
  void StopPolling();
120
111
  static void PollCallback(uv_poll_t* handle, int status, int events);
121
112
  };
@@ -157,19 +148,6 @@ TunDevice::~TunDevice() {
157
148
  CloseInternal();
158
149
  }
159
150
 
160
- void TunDevice::RegisterDevice() {
161
- std::lock_guard<std::mutex> lock(g_devices_mutex);
162
- g_active_devices.push_back(this);
163
- }
164
-
165
- void TunDevice::UnregisterDevice() {
166
- std::lock_guard<std::mutex> lock(g_devices_mutex);
167
- g_active_devices.erase(
168
- std::remove(g_active_devices.begin(), g_active_devices.end(), this),
169
- g_active_devices.end()
170
- );
171
- }
172
-
173
151
  void TunDevice::CloseInternal() {
174
152
  if (is_open_.exchange(false)) {
175
153
  StopPolling();
@@ -185,21 +163,15 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
185
163
  return Napi::Boolean::New(env, true);
186
164
  }
187
165
 
188
- if (g_shutdown_requested.load()) {
189
- Napi::Error::New(env, "Shutdown in progress").ThrowAsJavaScriptException();
190
- return Napi::Boolean::New(env, false);
191
- }
192
-
193
166
  #ifdef __APPLE__
194
- // macOS implementation using utun interfaces
167
+ // macOS: create utun interface via PF_SYSTEM control socket
195
168
  struct ctl_info ctlInfo;
196
169
  struct sockaddr_ctl sc;
197
170
 
198
171
  FileDescriptor temp_fd(socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL));
199
172
  if (!temp_fd.is_valid()) {
200
- std::string error = "Failed to create control socket: ";
201
- error += strerror(errno);
202
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
173
+ Napi::Error::New(env, std::string("Failed to create control socket: ") + strerror(errno))
174
+ .ThrowAsJavaScriptException();
203
175
  return Napi::Boolean::New(env, false);
204
176
  }
205
177
 
@@ -208,9 +180,8 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
208
180
  ctlInfo.ctl_name[sizeof(ctlInfo.ctl_name) - 1] = '\0';
209
181
 
210
182
  if (ioctl(temp_fd.get(), CTLIOCGINFO, &ctlInfo) < 0) {
211
- std::string error = "Failed to get utun control info: ";
212
- error += strerror(errno);
213
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
183
+ Napi::Error::New(env, std::string("Failed to get utun control info: ") + strerror(errno))
184
+ .ThrowAsJavaScriptException();
214
185
  return Napi::Boolean::New(env, false);
215
186
  }
216
187
 
@@ -220,7 +191,7 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
220
191
  sc.ss_sysaddr = SYSPROTO_CONTROL;
221
192
  sc.sc_id = ctlInfo.ctl_id;
222
193
 
223
- // Parse utun number if provided, otherwise use a default (utun0 = unit 1)
194
+ // Parse utun number if provided, otherwise auto-select (utun0 = unit 1)
224
195
  int utun_unit = 0;
225
196
  if (!name_.empty() && name_.find("utun") == 0) {
226
197
  try {
@@ -232,24 +203,20 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
232
203
 
233
204
  if (utun_unit > 0) {
234
205
  sc.sc_unit = utun_unit;
235
- // Try to connect with the specified unit
236
206
  if (connect(temp_fd.get(), (struct sockaddr*)&sc, sizeof(sc)) < 0) {
237
- std::string error = "Failed to connect to utun control socket with specified unit: ";
238
- error += strerror(errno);
239
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
207
+ Napi::Error::New(env, std::string("Failed to connect to utun with specified unit: ") + strerror(errno))
208
+ .ThrowAsJavaScriptException();
240
209
  return Napi::Boolean::New(env, false);
241
210
  }
242
211
  } else {
243
- // Find the first available unit
244
212
  bool connected = false;
245
213
  for (sc.sc_unit = 1; sc.sc_unit < 255; sc.sc_unit++) {
246
214
  if (connect(temp_fd.get(), (struct sockaddr*)&sc, sizeof(sc)) == 0) {
247
215
  connected = true;
248
216
  break;
249
217
  } else if (errno != EBUSY) {
250
- std::string error = "Failed to connect to utun control socket: ";
251
- error += strerror(errno);
252
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
218
+ Napi::Error::New(env, std::string("Failed to connect to utun control socket: ") + strerror(errno))
219
+ .ThrowAsJavaScriptException();
253
220
  return Napi::Boolean::New(env, false);
254
221
  }
255
222
  }
@@ -260,55 +227,49 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
260
227
  }
261
228
  }
262
229
 
263
- // Get the utun device name
264
230
  char utunname[20];
265
231
  socklen_t utunname_len = sizeof(utunname);
266
232
  if (getsockopt(temp_fd.get(), SYSPROTO_CONTROL, UTUN_OPT_IFNAME, utunname, &utunname_len) < 0) {
267
- std::string error = "Failed to get utun interface name: ";
268
- error += strerror(errno);
269
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
233
+ Napi::Error::New(env, std::string("Failed to get utun interface name: ") + strerror(errno))
234
+ .ThrowAsJavaScriptException();
270
235
  return Napi::Boolean::New(env, false);
271
236
  }
272
237
 
273
238
  name_ = std::string(utunname);
274
239
 
275
240
  #else
276
- // Linux implementation using TUN/TAP
277
- // First check if /dev/net/tun exists
241
+ // Linux: create TUN device via /dev/net/tun
278
242
  struct stat statbuf;
279
243
  if (stat("/dev/net/tun", &statbuf) != 0) {
280
- std::string error = "TUN/TAP device not available: /dev/net/tun does not exist. ";
281
- error += "Please ensure the TUN/TAP kernel module is loaded (modprobe tun).";
282
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
244
+ Napi::Error::New(env,
245
+ "TUN/TAP device not available: /dev/net/tun does not exist. "
246
+ "Please ensure the TUN/TAP kernel module is loaded (modprobe tun).")
247
+ .ThrowAsJavaScriptException();
283
248
  return Napi::Boolean::New(env, false);
284
249
  }
285
250
 
286
251
  FileDescriptor temp_fd(open("/dev/net/tun", O_RDWR));
287
252
  if (!temp_fd.is_valid()) {
288
- std::string error = "Failed to open /dev/net/tun: ";
289
- error += strerror(errno);
290
- error += ". This usually means you don't have sufficient permissions. ";
291
- error += "Try running with sudo or add your user to the 'tun' group.";
292
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
253
+ Napi::Error::New(env,
254
+ std::string("Failed to open /dev/net/tun: ") + strerror(errno) +
255
+ ". This usually means you don't have sufficient permissions. "
256
+ "Try running with sudo or add your user to the 'tun' group.")
257
+ .ThrowAsJavaScriptException();
293
258
  return Napi::Boolean::New(env, false);
294
259
  }
295
260
 
296
261
  struct ifreq ifr;
297
262
  memset(&ifr, 0, sizeof(ifr));
298
-
299
- // Set flags - IFF_TUN for TUN device, IFF_NO_PI to not provide packet info
300
263
  ifr.ifr_flags = IFF_TUN | IFF_NO_PI;
301
264
 
302
- // If name is provided, use it
303
265
  if (!name_.empty()) {
304
266
  strncpy(ifr.ifr_name, name_.c_str(), IFNAMSIZ - 1);
305
267
  ifr.ifr_name[IFNAMSIZ - 1] = '\0';
306
268
  }
307
269
 
308
270
  if (ioctl(temp_fd.get(), TUNSETIFF, &ifr) < 0) {
309
- std::string error = "Failed to configure TUN device: ";
310
- error += strerror(errno);
311
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
271
+ Napi::Error::New(env, std::string("Failed to configure TUN device: ") + strerror(errno))
272
+ .ThrowAsJavaScriptException();
312
273
  return Napi::Boolean::New(env, false);
313
274
  }
314
275
 
@@ -318,20 +279,17 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
318
279
  // Set non-blocking mode
319
280
  int flags = fcntl(temp_fd.get(), F_GETFL, 0);
320
281
  if (flags < 0) {
321
- std::string error = "Failed to get file descriptor flags: ";
322
- error += strerror(errno);
323
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
282
+ Napi::Error::New(env, std::string("Failed to get file descriptor flags: ") + strerror(errno))
283
+ .ThrowAsJavaScriptException();
324
284
  return Napi::Boolean::New(env, false);
325
285
  }
326
286
 
327
287
  if (fcntl(temp_fd.get(), F_SETFL, flags | O_NONBLOCK) < 0) {
328
- std::string error = "Failed to set non-blocking mode: ";
329
- error += strerror(errno);
330
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
288
+ Napi::Error::New(env, std::string("Failed to set non-blocking mode: ") + strerror(errno))
289
+ .ThrowAsJavaScriptException();
331
290
  return Napi::Boolean::New(env, false);
332
291
  }
333
292
 
334
- // Transfer ownership to member variable
335
293
  fd_ = std::move(temp_fd);
336
294
  is_open_ = true;
337
295
 
@@ -354,63 +312,44 @@ Napi::Value TunDevice::Read(const Napi::CallbackInfo& info) {
354
312
  return env.Null();
355
313
  }
356
314
 
357
- if (g_shutdown_requested.load()) {
358
- return Napi::Buffer<uint8_t>::New(env, 0);
359
- }
360
-
361
- // Read buffer size
362
- size_t buffer_size = 4096; // Default
315
+ size_t buffer_size = 4096;
363
316
  if (info.Length() > 0 && info[0].IsNumber()) {
364
317
  buffer_size = info[0].As<Napi::Number>().Uint32Value();
318
+ if (buffer_size == 0 || buffer_size > MAX_POLL_BUFFER) {
319
+ Napi::RangeError::New(env, "Read buffer size must be between 1 and " + std::to_string(MAX_POLL_BUFFER)).ThrowAsJavaScriptException();
320
+ return env.Null();
321
+ }
365
322
  }
366
323
 
367
- // Create buffer for reading
368
- Napi::Buffer<uint8_t> buffer = Napi::Buffer<uint8_t>::New(env, buffer_size);
369
- uint8_t* data = buffer.Data();
370
-
371
324
  #ifdef __APPLE__
372
- // On macOS, reads include a 4-byte protocol family prefix
373
- // We'll read the packet and then remove this prefix
374
- std::vector<uint8_t> tmp_buffer(buffer_size + 4);
375
-
376
- ssize_t bytes_read = read(fd_.get(), tmp_buffer.data(), buffer_size + 4);
377
- if (bytes_read <= 0) {
325
+ // macOS: reads include a 4-byte protocol family prefix that must be stripped
326
+ std::vector<uint8_t> raw(buffer_size + 4);
327
+ ssize_t n = read(fd_.get(), raw.data(), raw.size());
328
+ if (n <= 0) {
378
329
  if (errno == EAGAIN || errno == EWOULDBLOCK) {
379
- // No data available
380
330
  return Napi::Buffer<uint8_t>::New(env, 0);
381
331
  }
382
-
383
- // Error occurred
384
- std::string error = "Read error: ";
385
- error += strerror(errno);
386
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
332
+ Napi::Error::New(env, std::string("Read error: ") + strerror(errno))
333
+ .ThrowAsJavaScriptException();
387
334
  return env.Null();
388
335
  }
389
-
390
- // Skip the 4-byte protocol family header
391
- if (bytes_read > 4) {
392
- memcpy(data, tmp_buffer.data() + 4, bytes_read - 4);
393
- return Napi::Buffer<uint8_t>::Copy(env, data, bytes_read - 4);
394
- } else {
336
+ if (n <= 4) {
395
337
  return Napi::Buffer<uint8_t>::New(env, 0);
396
338
  }
339
+ return Napi::Buffer<uint8_t>::Copy(env, raw.data() + 4, n - 4);
397
340
  #else
398
- // On Linux, we read directly into the buffer
399
- ssize_t bytes_read = read(fd_.get(), data, buffer_size);
400
- if (bytes_read < 0) {
341
+ // Linux: raw IP packets directly
342
+ std::vector<uint8_t> raw(buffer_size);
343
+ ssize_t n = read(fd_.get(), raw.data(), raw.size());
344
+ if (n < 0) {
401
345
  if (errno == EAGAIN || errno == EWOULDBLOCK) {
402
- // No data available
403
346
  return Napi::Buffer<uint8_t>::New(env, 0);
404
347
  }
405
-
406
- // Error occurred
407
- std::string error = "Read error: ";
408
- error += strerror(errno);
409
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
348
+ Napi::Error::New(env, std::string("Read error: ") + strerror(errno))
349
+ .ThrowAsJavaScriptException();
410
350
  return env.Null();
411
351
  }
412
-
413
- return Napi::Buffer<uint8_t>::Copy(env, data, bytes_read);
352
+ return Napi::Buffer<uint8_t>::Copy(env, raw.data(), n);
414
353
  #endif
415
354
  }
416
355
 
@@ -423,11 +362,6 @@ Napi::Value TunDevice::Write(const Napi::CallbackInfo& info) {
423
362
  return Napi::Number::New(env, -1);
424
363
  }
425
364
 
426
- if (g_shutdown_requested.load()) {
427
- Napi::Error::New(env, "Shutdown in progress").ThrowAsJavaScriptException();
428
- return Napi::Number::New(env, -1);
429
- }
430
-
431
365
  if (info.Length() < 1 || !info[0].IsBuffer()) {
432
366
  Napi::TypeError::New(env, "Expected buffer as first argument").ThrowAsJavaScriptException();
433
367
  return Napi::Number::New(env, -1);
@@ -438,34 +372,26 @@ Napi::Value TunDevice::Write(const Napi::CallbackInfo& info) {
438
372
  size_t length = buffer.Length();
439
373
 
440
374
  #ifdef __APPLE__
441
- // On macOS, we need to prepend a 4-byte protocol family header
442
- // For IPv6, the protocol family is AF_INET6 (30 on macOS)
443
- std::vector<uint8_t> tmp_buffer(length + 4);
375
+ // macOS: prepend 4-byte AF_INET6 protocol family header
376
+ std::vector<uint8_t> frame(length + 4);
444
377
  uint32_t family = htonl(AF_INET6);
378
+ memcpy(frame.data(), &family, 4);
379
+ memcpy(frame.data() + 4, data, length);
445
380
 
446
- memcpy(tmp_buffer.data(), &family, 4);
447
- memcpy(tmp_buffer.data() + 4, data, length);
448
-
449
- ssize_t bytes_written = write(fd_.get(), tmp_buffer.data(), length + 4);
381
+ ssize_t bytes_written = write(fd_.get(), frame.data(), frame.size());
450
382
  if (bytes_written < 0) {
451
- std::string error = "Write error: ";
452
- error += strerror(errno);
453
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
383
+ Napi::Error::New(env, std::string("Write error: ") + strerror(errno))
384
+ .ThrowAsJavaScriptException();
454
385
  return Napi::Number::New(env, -1);
455
386
  }
456
-
457
- // Return the original data length without the header
458
387
  return Napi::Number::New(env, bytes_written > 4 ? bytes_written - 4 : 0);
459
388
  #else
460
- // On Linux, we write directly from the buffer
461
389
  ssize_t bytes_written = write(fd_.get(), data, length);
462
390
  if (bytes_written < 0) {
463
- std::string error = "Write error: ";
464
- error += strerror(errno);
465
- Napi::Error::New(env, error).ThrowAsJavaScriptException();
391
+ Napi::Error::New(env, std::string("Write error: ") + strerror(errno))
392
+ .ThrowAsJavaScriptException();
466
393
  return Napi::Number::New(env, -1);
467
394
  }
468
-
469
395
  return Napi::Number::New(env, bytes_written);
470
396
  #endif
471
397
  }
@@ -488,12 +414,25 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
488
414
  Napi::Error::New(env, "Device not open").ThrowAsJavaScriptException();
489
415
  return env.Null();
490
416
  }
491
- if (!info[0].IsFunction()) {
417
+
418
+ if (info.Length() < 1 || !info[0].IsFunction()) {
492
419
  Napi::TypeError::New(env, "Expected function as first argument").ThrowAsJavaScriptException();
493
420
  return env.Null();
494
421
  }
422
+
495
423
  StopPolling();
496
424
 
425
+ // Optional buffer size as second argument (default: MAX_POLL_BUFFER)
426
+ poll_buffer_size_ = MAX_POLL_BUFFER;
427
+ if (info.Length() > 1 && info[1].IsNumber()) {
428
+ auto size = info[1].As<Napi::Number>().Uint32Value();
429
+ if (size == 0 || size > MAX_POLL_BUFFER) {
430
+ Napi::RangeError::New(env, "Buffer size must be between 1 and " + std::to_string(MAX_POLL_BUFFER)).ThrowAsJavaScriptException();
431
+ return env.Null();
432
+ }
433
+ poll_buffer_size_ = size;
434
+ }
435
+
497
436
  tsfn_ = Napi::ThreadSafeFunction::New(
498
437
  env,
499
438
  info[0].As<Napi::Function>(),
@@ -502,27 +441,46 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
502
441
  1
503
442
  );
504
443
 
444
+ uv_loop_t* loop = nullptr;
445
+ napi_status napi_st = napi_get_uv_event_loop(env, &loop);
446
+ if (napi_st != napi_ok || loop == nullptr) {
447
+ tsfn_.Release();
448
+ tsfn_ = nullptr;
449
+ Napi::Error::New(env, "Failed to acquire event loop").ThrowAsJavaScriptException();
450
+ return env.Null();
451
+ }
452
+
505
453
  auto handle = std::make_unique<uv_poll_t>();
506
- if (uv_poll_init(uv_default_loop(), handle.get(), fd_.get()) != 0) {
507
- Napi::Error::New(env, "Failed to initialize poll handle").ThrowAsJavaScriptException();
508
- return env.Null();
454
+ if (uv_poll_init(loop, handle.get(), fd_.get()) != 0) {
455
+ tsfn_.Release();
456
+ tsfn_ = nullptr;
457
+ Napi::Error::New(env, "Failed to initialize poll handle").ThrowAsJavaScriptException();
458
+ return env.Null();
509
459
  }
510
460
 
511
461
  handle->data = this;
512
462
  if (uv_poll_start(handle.get(), UV_READABLE, PollCallback) != 0) {
513
- Napi::Error::New(env, "Failed to start polling").ThrowAsJavaScriptException();
514
- return env.Null();
463
+ // Properly close the initialized-but-not-started handle
464
+ uv_close(reinterpret_cast<uv_handle_t*>(handle.release()), [](uv_handle_t* h) {
465
+ delete reinterpret_cast<uv_poll_t*>(h);
466
+ });
467
+ tsfn_.Release();
468
+ tsfn_ = nullptr;
469
+ Napi::Error::New(env, "Failed to start polling").ThrowAsJavaScriptException();
470
+ return env.Null();
515
471
  }
516
472
 
517
473
  poll_handle_ = handle.release();
518
-
519
474
  return env.Undefined();
520
475
  }
521
476
 
522
477
  void TunDevice::StopPolling() {
523
478
  if (poll_handle_) {
524
479
  uv_poll_stop(poll_handle_);
525
- delete poll_handle_;
480
+ // Must use uv_close before freeing a libuv handle
481
+ uv_close(reinterpret_cast<uv_handle_t*>(poll_handle_), [](uv_handle_t* handle) {
482
+ delete reinterpret_cast<uv_poll_t*>(handle);
483
+ });
526
484
  poll_handle_ = nullptr;
527
485
  }
528
486
  if (tsfn_) {
@@ -534,6 +492,10 @@ void TunDevice::StopPolling() {
534
492
  void TunDevice::PollCallback(uv_poll_t* handle, int status, int events) {
535
493
  if (status < 0) {
536
494
  fprintf(stderr, "tuntap poll error: %s\n", uv_strerror(status));
495
+ auto* self = static_cast<TunDevice*>(handle->data);
496
+ if (self) {
497
+ self->StopPolling();
498
+ }
537
499
  return;
538
500
  }
539
501
 
@@ -541,35 +503,36 @@ void TunDevice::PollCallback(uv_poll_t* handle, int status, int events) {
541
503
  return;
542
504
  }
543
505
 
544
- TunDevice* self = static_cast<TunDevice*>(handle->data);
506
+ auto* self = static_cast<TunDevice*>(handle->data);
545
507
  if (!self || !self->is_open_.load() || !self->fd_.is_valid()) {
546
508
  return;
547
509
  }
548
510
 
549
- std::vector<uint8_t> buffer(4096);
511
+ std::vector<uint8_t> buffer(self->poll_buffer_size_ + UTUN_HEADER_SIZE);
550
512
  ssize_t bytes_read = read(self->fd_.get(), buffer.data(), buffer.size());
551
513
 
552
- if (bytes_read <= 0) {
514
+ if (bytes_read == 0) {
515
+ self->StopPolling();
516
+ return;
517
+ }
518
+ if (bytes_read < 0) {
553
519
  if (errno != EAGAIN && errno != EWOULDBLOCK) {
554
- fprintf(stderr, "tuntap read error: %s\n", strerror(errno));
520
+ fprintf(stderr, "tuntap read error: %s\n", strerror(errno));
521
+ self->StopPolling();
555
522
  }
556
523
  return;
557
524
  }
558
525
 
559
- #ifdef __APPLE__
560
- if (bytes_read > 4) {
561
- self->tsfn_.BlockingCall([buffer = std::move(buffer), bytes_read](Napi::Env env, Napi::Function jsCallback) {
562
- jsCallback.Call({ Napi::Buffer<uint8_t>::Copy(env, buffer.data() + 4, bytes_read - 4) });
563
- });
526
+ if (bytes_read > UTUN_HEADER_SIZE) {
527
+ self->tsfn_.BlockingCall(
528
+ [buf = std::move(buffer), bytes_read](Napi::Env env, Napi::Function jsCallback) {
529
+ if (env == nullptr || jsCallback.IsEmpty()) return;
530
+ jsCallback.Call({ Napi::Buffer<uint8_t>::Copy(env, buf.data() + UTUN_HEADER_SIZE, bytes_read - UTUN_HEADER_SIZE) });
531
+ }
532
+ );
564
533
  }
565
- #else
566
- self->tsfn_.BlockingCall([buffer = std::move(buffer), bytes_read](Napi::Env env, Napi::Function jsCallback) {
567
- jsCallback.Call({ Napi::Buffer<uint8_t>::Copy(env, buffer.data(), bytes_read) });
568
- });
569
- #endif
570
534
  }
571
535
 
572
- // Module initialization
573
536
  Napi::Object Init(Napi::Env env, Napi::Object exports) {
574
537
  return TunDevice::Init(env, exports);
575
538
  }