appium-ios-tuntap 0.1.5 → 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,9 @@
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
+
1
7
  ## [0.1.5](https://github.com/appium/appium-ios-tuntap/compare/v0.1.4...v0.1.5) (2026-04-09)
2
8
 
3
9
  ### Code Refactoring
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/d5aab3f73716915039a0303184de39d0/.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;
17
+ private _isOpen;
18
+ private _isClosed;
18
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,26 +32,44 @@ 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;
56
+ _isOpen;
57
+ _isClosed;
61
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
  };
@@ -79,42 +78,59 @@ export class TunTap {
79
78
  process.removeListener('exit', cleanup);
80
79
  };
81
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
+ }
97
+ }
82
98
  open() {
83
- if (this.isClosed) {
99
+ if (this._isClosed) {
84
100
  throw new TunTapError('Device has been closed and cannot be reopened');
85
101
  }
86
- if (!this.isOpen) {
102
+ if (!this._isOpen) {
87
103
  try {
88
- this.isOpen = this.device.open();
89
- if (!this.isOpen) {
104
+ this._isOpen = this.device.open();
105
+ if (!this._isOpen) {
90
106
  throw new TunTapDeviceError('Failed to open TUN device');
91
107
  }
92
108
  }
93
109
  catch (err) {
94
- // Re-throw with more specific error types
95
- if (err.message?.includes('Permission denied') || err.message?.includes('sudo')) {
96
- throw new TunTapPermissionError(err.message);
110
+ const message = err.message ?? '';
111
+ if (message.includes('Permission denied') || message.includes('sudo')) {
112
+ throw new TunTapPermissionError(message);
97
113
  }
98
- else if (err.message?.includes('not available') || err.message?.includes('does not exist')) {
99
- throw new TunTapDeviceError(err.message);
114
+ if (message.includes('not available') || message.includes('does not exist')) {
115
+ throw new TunTapDeviceError(message);
100
116
  }
101
117
  throw err;
102
118
  }
103
119
  }
104
- return this.isOpen;
120
+ return this._isOpen;
105
121
  }
106
122
  close() {
107
123
  if (this.removeExitListener) {
108
124
  this.removeExitListener();
109
125
  this.removeExitListener = null;
110
126
  }
111
- if (!this.isClosed) {
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;
133
+ this._isClosed = true;
118
134
  }
119
135
  catch (err) {
120
136
  throw new TunTapError(`Failed to close device: ${err.message}`);
@@ -122,15 +138,10 @@ export class TunTap {
122
138
  }
123
139
  return true;
124
140
  }
125
- read(maxSize = 4096) {
126
- if (!this.isOpen) {
127
- throw new TunTapError('Device not open');
128
- }
129
- if (this.isClosed) {
130
- throw new TunTapError('Device has been closed');
131
- }
132
- if (maxSize <= 0 || maxSize > 65536) {
133
- 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`);
134
145
  }
135
146
  try {
136
147
  return this.device.read(maxSize);
@@ -140,20 +151,15 @@ export class TunTap {
140
151
  }
141
152
  }
142
153
  write(data) {
143
- if (!this.isOpen) {
144
- throw new TunTapError('Device not open');
145
- }
146
- if (this.isClosed) {
147
- throw new TunTapError('Device has been closed');
148
- }
154
+ this.assertReady();
149
155
  if (!Buffer.isBuffer(data)) {
150
156
  throw new TypeError('Data must be a Buffer');
151
157
  }
152
158
  if (data.length === 0) {
153
159
  return 0;
154
160
  }
155
- if (data.length > 65536) {
156
- 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)`);
157
163
  }
158
164
  try {
159
165
  const result = this.device.write(data);
@@ -166,57 +172,44 @@ export class TunTap {
166
172
  throw new TunTapError(`Write failed: ${err.message}`);
167
173
  }
168
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
+ }
169
189
  get name() {
170
190
  return this.device.getName();
171
191
  }
172
192
  get fd() {
173
193
  return this.device.getFd();
174
194
  }
175
- async configure(address, mtu = 1500) {
176
- if (!this.isOpen) {
177
- throw new TunTapError('Device not open');
178
- }
179
- if (this.isClosed) {
180
- throw new TunTapError('Device has been closed');
181
- }
195
+ async configure(address, mtu = DEFAULT_MTU) {
196
+ this.assertReady();
182
197
  if (!isIPv6(address)) {
183
198
  throw new TypeError('Invalid IPv6 address format');
184
199
  }
185
- // Validate MTU
186
- if (mtu < 1280 || mtu > 65535) {
187
- 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}`);
188
202
  }
189
- const platform = process.platform;
190
203
  try {
191
- if (platform === 'darwin') {
204
+ if (PLATFORM === 'darwin') {
192
205
  await execFileAsync('sudo', ['ifconfig', this.name, 'inet6', address, 'prefixlen', '64', 'up']);
193
206
  await execFileAsync('sudo', ['ifconfig', this.name, 'mtu', String(mtu)]);
194
207
  }
195
- else if (platform === 'linux') {
196
- try {
197
- await execFileAsync('which', ['ip']);
198
- }
199
- catch {
200
- throw new TunTapError('The "ip" command is not available. Please install the iproute2 package (e.g., sudo apt install iproute2)');
201
- }
202
- try {
203
- await execFileAsync('sudo', ['ip', '-6', 'addr', 'add', `${address}/64`, 'dev', this.name]);
204
- await execFileAsync('sudo', ['ip', 'link', 'set', 'dev', this.name, 'up', 'mtu', String(mtu)]);
205
- }
206
- catch (err) {
207
- if (err.message.includes('Permission denied')) {
208
- throw new TunTapPermissionError(`Permission denied when configuring network interface. Make sure you have sudo privileges or run the application with sudo.`);
209
- }
210
- else if (err.message.includes('File exists')) {
211
- log.warn(`Address ${address} may already be configured on ${this.name}`);
212
- }
213
- else {
214
- throw err;
215
- }
216
- }
208
+ else if (PLATFORM === 'linux') {
209
+ await this.configureLinux(address, mtu);
217
210
  }
218
211
  else {
219
- throw new TunTapError(`Unsupported platform: ${platform}`);
212
+ throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
220
213
  }
221
214
  }
222
215
  catch (err) {
@@ -226,133 +219,159 @@ export class TunTap {
226
219
  throw new TunTapError(`Failed to configure TUN interface: ${err.message}`);
227
220
  }
228
221
  }
229
- async addRoute(destination) {
230
- if (!this.isOpen) {
231
- throw new TunTapError('Device not open');
222
+ async configureLinux(address, mtu) {
223
+ try {
224
+ await execFileAsync('which', ['ip']);
232
225
  }
233
- if (this.isClosed) {
234
- 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');
235
248
  }
236
249
  if (!isValidIPv6Route(destination)) {
237
- 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)');
238
251
  }
239
- const platform = process.platform;
240
252
  try {
241
- if (platform === 'darwin') {
253
+ if (PLATFORM === 'darwin') {
242
254
  await execFileAsync('sudo', ['route', '-n', 'add', '-inet6', destination, '-interface', this.name]);
243
255
  }
244
- else if (platform === 'linux') {
245
- try {
246
- await execFileAsync('sudo', ['ip', '-6', 'route', 'add', destination, 'dev', this.name]);
247
- }
248
- catch (err) {
249
- if (err.message.includes('Permission denied')) {
250
- throw new TunTapPermissionError(`Permission denied when adding route. Make sure you have sudo privileges or run the application with sudo.`);
251
- }
252
- else if (err.message.includes('File exists')) {
253
- log.info(`Route to ${destination} already exists`);
254
- }
255
- else {
256
- throw err;
257
- }
258
- }
256
+ else if (PLATFORM === 'linux') {
257
+ await this.addRouteLinux(destination);
259
258
  }
260
259
  else {
261
- throw new TunTapError(`Unsupported platform: ${platform}`);
260
+ throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
262
261
  }
263
262
  }
264
263
  catch (err) {
265
264
  if (err instanceof TunTapError) {
266
265
  throw err;
267
266
  }
268
- if (!err.message.includes('Route to') && !err.message.includes('already exists')) {
269
- 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.');
278
+ }
279
+ if (message.includes('File exists')) {
280
+ log.info(`Route to ${destination} already exists`);
281
+ return;
270
282
  }
283
+ throw err;
271
284
  }
272
285
  }
273
286
  async removeRoute(destination) {
274
- if (!this.isOpen) {
275
- 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');
276
290
  }
277
291
  if (!isValidIPv6Route(destination)) {
278
- 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');
279
293
  }
280
- const platform = process.platform;
281
294
  try {
282
- if (platform === 'darwin') {
295
+ if (PLATFORM === 'darwin') {
283
296
  await execFileAsync('sudo', ['route', '-n', 'delete', '-inet6', destination]);
284
297
  }
285
- else if (platform === 'linux') {
298
+ else if (PLATFORM === 'linux') {
286
299
  await execFileAsync('sudo', ['ip', '-6', 'route', 'del', destination, 'dev', this.name]);
287
300
  }
288
301
  else {
289
- throw new TunTapError(`Unsupported platform: ${platform}`);
302
+ throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
290
303
  }
291
304
  }
292
305
  catch (err) {
293
- if (!err.message.includes('not in table') && !err.message.includes('No such process')) {
294
- 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;
295
309
  }
310
+ throw new TunTapError(`Failed to remove route: ${message}`);
296
311
  }
297
312
  }
298
313
  /**
299
- * Get interface statistics
314
+ * Get interface statistics.
300
315
  */
301
316
  async getStats() {
302
- if (!this.isOpen) {
303
- throw new TunTapError('Device not open');
304
- }
305
- const platform = process.platform;
317
+ this.assertReady();
306
318
  try {
307
- if (platform === 'darwin') {
308
- const { stdout } = await execFileAsync('netstat', ['-I', this.name, '-b']);
309
- const lines = stdout.trim().split('\n');
310
- if (lines.length < 2) {
311
- throw new Error('Unexpected netstat output');
312
- }
313
- const stats = lines[1].split(/\s+/);
314
- return {
315
- rxPackets: parseInt(stats[4], 10) || 0,
316
- rxErrors: parseInt(stats[5], 10) || 0,
317
- rxBytes: parseInt(stats[6], 10) || 0,
318
- txPackets: parseInt(stats[7], 10) || 0,
319
- txErrors: parseInt(stats[8], 10) || 0,
320
- txBytes: parseInt(stats[9], 10) || 0,
321
- };
322
- }
323
- else if (platform === 'linux') {
324
- const { stdout } = await execFileAsync('ip', ['-s', 'link', 'show', this.name]);
325
- const lines = stdout.trim().split('\n');
326
- let rxIndex = -1;
327
- let txIndex = -1;
328
- for (let i = 0; i < lines.length; i++) {
329
- if (lines[i].includes('RX:')) {
330
- rxIndex = i + 1;
331
- }
332
- if (lines[i].includes('TX:')) {
333
- txIndex = i + 1;
334
- }
335
- }
336
- if (rxIndex === -1 || txIndex === -1) {
337
- throw new Error('Could not parse interface statistics');
338
- }
339
- const rxStats = lines[rxIndex].trim().split(/\s+/);
340
- const txStats = lines[txIndex].trim().split(/\s+/);
341
- return {
342
- rxBytes: parseInt(rxStats[0], 10) || 0,
343
- rxPackets: parseInt(rxStats[1], 10) || 0,
344
- rxErrors: parseInt(rxStats[2], 10) || 0,
345
- txBytes: parseInt(txStats[0], 10) || 0,
346
- txPackets: parseInt(txStats[1], 10) || 0,
347
- txErrors: parseInt(txStats[2], 10) || 0,
348
- };
319
+ if (PLATFORM === 'darwin') {
320
+ return await this.getStatsDarwin();
349
321
  }
350
- else {
351
- throw new TunTapError(`Unsupported platform: ${platform}`);
322
+ if (PLATFORM === 'linux') {
323
+ return await this.getStatsLinux();
352
324
  }
325
+ throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
353
326
  }
354
327
  catch (err) {
328
+ if (err instanceof TunTapError) {
329
+ throw err;
330
+ }
355
331
  throw new TunTapError(`Failed to get interface statistics: ${err.message}`);
356
332
  }
357
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
+ }
358
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-tuntap",
3
- "version": "0.1.5",
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
  }