appium-ios-tuntap 0.4.0 → 0.4.1

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.4.1](https://github.com/appium/appium-ios-tuntap/compare/v0.4.0...v0.4.1) (2026-05-31)
2
+
3
+ ### Bug Fixes
4
+
5
+ * Improve tunnel performance ([#45](https://github.com/appium/appium-ios-tuntap/issues/45)) ([576d353](https://github.com/appium/appium-ios-tuntap/commit/576d3535137bb0cf79634ab820b3675db6cbbb86))
6
+
1
7
  ## [0.4.0](https://github.com/appium/appium-ios-tuntap/compare/v0.3.0...v0.4.0) (2026-05-30)
2
8
 
3
9
  ### Features
package/lib/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { TunTapDeviceError, TunTapError, TunTapPermissionError } from './errors.js';
2
2
  export { TunTap, type PacketCallback } from './TunTap.js';
3
- export * from './tunnel.js';
3
+ export * from './tunnel/index.js';
package/lib/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { TunTapDeviceError, TunTapError, TunTapPermissionError } from './errors.js';
2
2
  export { TunTap } from './TunTap.js';
3
- export * from './tunnel.js';
3
+ export * from './tunnel/index.js';
@@ -0,0 +1,10 @@
1
+ /** CDTunnel lockdown handshake MTU (IPv6 minimum). */
2
+ export declare const CD_TUNNEL_MTU = 1280;
3
+ export declare const CD_TUNNEL_MAGIC = "CDTunnel";
4
+ export declare const CD_TUNNEL_MAGIC_SIZE = 8;
5
+ export declare const CD_TUNNEL_HEADER_SIZE: number;
6
+ export declare const CD_TUNNEL_HANDSHAKE_TIMEOUT_MS = 30000;
7
+ export declare const IPV6_HEADER_SIZE = 40;
8
+ export declare const IPV6_VERSION = 6;
9
+ export declare const IPPROTO_TCP = 6;
10
+ export declare const IPPROTO_UDP = 17;
@@ -0,0 +1,10 @@
1
+ /** CDTunnel lockdown handshake MTU (IPv6 minimum). */
2
+ export const CD_TUNNEL_MTU = 1280;
3
+ export const CD_TUNNEL_MAGIC = 'CDTunnel';
4
+ export const CD_TUNNEL_MAGIC_SIZE = 8;
5
+ export const CD_TUNNEL_HEADER_SIZE = CD_TUNNEL_MAGIC_SIZE + 2;
6
+ export const CD_TUNNEL_HANDSHAKE_TIMEOUT_MS = 30_000;
7
+ export const IPV6_HEADER_SIZE = 40;
8
+ export const IPV6_VERSION = 6;
9
+ export const IPPROTO_TCP = 6;
10
+ export const IPPROTO_UDP = 17;
@@ -0,0 +1,2 @@
1
+ export type { PacketConsumer, PacketData, TunnelConnection, TunnelManagerEvents } from './types.js';
2
+ export { TunnelManager, connectToTunnelLockdown, exchangeCoreTunnelParameters } from './manager.js';
@@ -0,0 +1 @@
1
+ export { TunnelManager, connectToTunnelLockdown, exchangeCoreTunnelParameters } from './manager.js';
@@ -1,57 +1,7 @@
1
- import { TunTap } from './TunTap.js';
1
+ import { TunTap } from '../TunTap.js';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import type { Socket } from 'node:net';
4
- import { Buffer } from 'node:buffer';
5
- export interface PacketData {
6
- protocol: 'TCP' | 'UDP';
7
- src: string;
8
- dst: string;
9
- sourcePort: number;
10
- destPort: number;
11
- payload: Buffer;
12
- }
13
- /**
14
- * Event names and listener argument tuples for {@link TunnelManager}
15
- * (matches Node’s `EventEmitter` event map shape).
16
- *
17
- * @example
18
- * tunnelManager.on('data', (packet) => {
19
- * // `packet` is PacketData
20
- * });
21
- */
22
- export interface PacketConsumer {
23
- /**
24
- * Invoked for each parsed TCP/UDP payload extracted from the tunnel stream.
25
- *
26
- * @param packet — decoded addresses, ports, and payload
27
- */
28
- onPacket(packet: PacketData): void;
29
- }
30
- export interface TunnelManagerEvents {
31
- data: [packet: PacketData];
32
- }
33
- export interface TunnelConnection {
34
- Address: string;
35
- RsdPort?: number;
36
- tunnelManager: TunnelManager;
37
- /** Tear down the tunnel, close the TUN device, and end the socket when appropriate. */
38
- closer: () => Promise<void>;
39
- /** @param consumer — receives packets for the lifetime of the registration */
40
- addPacketConsumer(consumer: PacketConsumer): void;
41
- /** @param consumer — must be the same reference passed to {@link TunnelConnection.addPacketConsumer} */
42
- removePacketConsumer(consumer: PacketConsumer): void;
43
- /** @returns async iterator of packets until the tunnel is stopped */
44
- getPacketStream(): AsyncIterable<PacketData>;
45
- }
46
- interface TunnelClientParameters {
47
- address: string;
48
- mtu: number;
49
- }
50
- interface TunnelInfo {
51
- clientParameters: TunnelClientParameters;
52
- serverAddress: string;
53
- serverRSDPort?: number;
54
- }
4
+ import type { PacketConsumer, PacketData, TunnelConnection, TunnelInfo, TunnelManagerEvents } from './types.js';
55
5
  /**
56
6
  * Bridges a CoreDevice tunnel `Socket` and a {@link TunTap} interface: IPv6 framing, TUN I/O, and packet fan-out.
57
7
  * Emits {@link TunnelManagerEvents} (currently `data` with {@link PacketData}) for TCP/UDP packets, same as registered consumers.
@@ -59,14 +9,11 @@ interface TunnelInfo {
59
9
  export declare class TunnelManager extends EventEmitter<TunnelManagerEvents> {
60
10
  private tun;
61
11
  private cancelled;
62
- private readInterval;
12
+ private mtu;
63
13
  private buffer;
64
- private packetConsumers;
65
- private packetQueue;
14
+ private readonly packetConsumers;
66
15
  private deviceConn;
67
16
  private cleanupPromise;
68
- /** Creates a manager with no TUN device until {@link TunnelManager.setupInterface} succeeds. */
69
- constructor();
70
17
  /**
71
18
  * Register a listener for parsed tunnel packets (in addition to the `data` event).
72
19
  *
@@ -108,7 +55,11 @@ export declare class TunnelManager extends EventEmitter<TunnelManagerEvents> {
108
55
  * @returns the same promise if already stopping/stopped
109
56
  */
110
57
  stop(): Promise<void>;
58
+ private hasPacketTap;
111
59
  private processBuffer;
60
+ private writeDeviceFrameToTun;
61
+ private tapL4Packet;
62
+ private dispatchPacketData;
112
63
  private startTunReadLoop;
113
64
  private _performStop;
114
65
  }
@@ -126,4 +77,3 @@ export declare function exchangeCoreTunnelParameters(socket: Socket): Promise<Tu
126
77
  * @returns connection handle with {@link TunnelConnection.closer} and packet APIs
127
78
  */
128
79
  export declare function connectToTunnelLockdown(secureServiceSocket: Socket): Promise<TunnelConnection>;
129
- export {};
@@ -1,32 +1,20 @@
1
- import { log } from './logger.js';
2
- import { TunTap } from './TunTap.js';
1
+ import { log } from '../logger.js';
2
+ import { TunTap } from '../TunTap.js';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import { Buffer } from 'node:buffer';
5
+ import { CD_TUNNEL_HANDSHAKE_TIMEOUT_MS, CD_TUNNEL_HEADER_SIZE, CD_TUNNEL_MAGIC, CD_TUNNEL_MAGIC_SIZE, CD_TUNNEL_MTU, IPV6_HEADER_SIZE, IPV6_VERSION, IPPROTO_TCP, IPPROTO_UDP, } from './constants.js';
5
6
  /**
6
7
  * Bridges a CoreDevice tunnel `Socket` and a {@link TunTap} interface: IPv6 framing, TUN I/O, and packet fan-out.
7
8
  * Emits {@link TunnelManagerEvents} (currently `data` with {@link PacketData}) for TCP/UDP packets, same as registered consumers.
8
9
  */
9
10
  export class TunnelManager extends EventEmitter {
10
- tun;
11
- cancelled;
12
- readInterval;
13
- buffer;
14
- packetConsumers;
15
- packetQueue;
16
- deviceConn;
17
- cleanupPromise;
18
- /** Creates a manager with no TUN device until {@link TunnelManager.setupInterface} succeeds. */
19
- constructor() {
20
- super();
21
- this.tun = null;
22
- this.cancelled = false;
23
- this.readInterval = null;
24
- this.buffer = Buffer.alloc(0);
25
- this.packetConsumers = new Set();
26
- this.packetQueue = [];
27
- this.deviceConn = null;
28
- this.cleanupPromise = null;
29
- }
11
+ tun = null;
12
+ cancelled = false;
13
+ mtu = CD_TUNNEL_MTU;
14
+ buffer = Buffer.alloc(0);
15
+ packetConsumers = new Set();
16
+ deviceConn = null;
17
+ cleanupPromise = null;
30
18
  /**
31
19
  * Register a listener for parsed tunnel packets (in addition to the `data` event).
32
20
  *
@@ -99,6 +87,7 @@ export class TunnelManager extends EventEmitter {
99
87
  throw new Error('Failed to open TUN device');
100
88
  }
101
89
  log.debug(`Opened TUN device: ${this.tun.name}`);
90
+ this.mtu = tunnelInfo.clientParameters.mtu;
102
91
  // Configure the TUN device with IPv6 address and MTU
103
92
  await this.tun.configure(tunnelInfo.clientParameters.address, tunnelInfo.clientParameters.mtu);
104
93
  // Add route for the server address
@@ -182,162 +171,107 @@ export class TunnelManager extends EventEmitter {
182
171
  this.cleanupPromise = this._performStop();
183
172
  return this.cleanupPromise;
184
173
  }
174
+ hasPacketTap() {
175
+ return this.packetConsumers.size > 0 || this.listenerCount('data') > 0;
176
+ }
185
177
  processBuffer() {
186
178
  let offset = 0;
187
- // Process as many complete packets as available
188
- while (offset + 40 <= this.buffer.length) {
189
- // Extract IPv6 header (fixed 40 bytes)
190
- const header = this.buffer.slice(offset, offset + 40);
191
- // Ensure this is an IPv6 packet (version 6)
192
- const version = (header[0] >> 4) & 0x0f;
193
- if (version !== 6) {
179
+ while (offset + IPV6_HEADER_SIZE <= this.buffer.length) {
180
+ const frame = nextIpv6Frame(this.buffer, offset);
181
+ if (frame.kind === 'incomplete') {
182
+ break;
183
+ }
184
+ if (frame.kind === 'resync') {
194
185
  offset++;
195
186
  continue;
196
187
  }
197
- // Get payload length from the IPv6 header
198
- const payloadLength = header.readUInt16BE(4);
199
- // Ensure we have the full packet (IPv6 header + payload)
200
- if (offset + 40 + payloadLength > this.buffer.length) {
201
- break; // Wait for more data
188
+ if (!this.tun) {
189
+ log.error('TUN device is null during packet processing');
190
+ break;
202
191
  }
203
- // Extract the complete IPv6 packet
204
- const packet = this.buffer.slice(offset, offset + 40 + payloadLength);
205
- // Extract source and destination IPv6 addresses
206
- const src = formatIPv6Address(packet.slice(8, 24));
207
- const dst = formatIPv6Address(packet.slice(24, 40));
208
- // Get the IPv6 next header value
209
- const nextHeader = header[6];
210
- log.debug(`Processing packet: nextHeader=${nextHeader}, totalLength=${40 + payloadLength}`);
211
192
  try {
212
- if (!this.tun) {
213
- log.error('TUN device is null during packet processing');
214
- break;
215
- }
216
- const bytesWritten = this.tun.write(packet);
217
- log.debug(`Device → TUN: ${bytesWritten} bytes, IPv6 src=${src}, dst=${dst}`);
218
- // Handle UDP packets (nextHeader === 17)
219
- if (nextHeader === 17) {
220
- const payload = packet.slice(40);
221
- log.debug(`UDP packet detected: payload length=${payload.length}`);
222
- if (payload.length < 8) {
223
- log.debug('UDP payload too short, not emitting event.');
224
- }
225
- else {
226
- const sourcePort = payload.readUInt16BE(0);
227
- const destPort = payload.readUInt16BE(2);
228
- const udpPayload = payload.slice(8);
229
- const packetData = {
230
- protocol: 'UDP',
231
- src,
232
- dst,
233
- sourcePort,
234
- destPort,
235
- payload: udpPayload,
236
- };
237
- this.emit('data', packetData);
238
- this.packetConsumers.forEach((consumer) => {
239
- try {
240
- consumer.onPacket(packetData);
241
- }
242
- catch (err) {
243
- log.error('Error in packet consumer:', err);
244
- }
245
- });
246
- log.debug('Emitted data event for UDP packet');
247
- }
248
- }
249
- // Handle TCP packets (nextHeader === 6)
250
- else if (nextHeader === 6) {
251
- const tcpHeaderStart = 40;
252
- if (packet.length < tcpHeaderStart + 20) {
253
- log.debug('TCP packet too short for minimum header, skipping.');
254
- }
255
- else {
256
- const sourcePort = packet.readUInt16BE(tcpHeaderStart);
257
- const destPort = packet.readUInt16BE(tcpHeaderStart + 2);
258
- const dataOffsetByte = packet.readUInt8(tcpHeaderStart + 12);
259
- const tcpHeaderLength = (dataOffsetByte >> 4) * 4;
260
- if (packet.length < tcpHeaderStart + tcpHeaderLength) {
261
- log.debug('TCP header length exceeds packet length, skipping.');
262
- }
263
- else {
264
- const tcpPayload = packet.slice(tcpHeaderStart + tcpHeaderLength);
265
- log.debug(`TCP packet detected: headerLength=${tcpHeaderLength}, payload length=${tcpPayload.length}`);
266
- const packetData = {
267
- protocol: 'TCP',
268
- src,
269
- dst,
270
- sourcePort,
271
- destPort,
272
- payload: tcpPayload,
273
- };
274
- this.emit('data', packetData);
275
- this.packetConsumers.forEach((consumer) => {
276
- try {
277
- consumer.onPacket(packetData);
278
- }
279
- catch (err) {
280
- log.error('Error in packet consumer:', err);
281
- }
282
- });
283
- log.debug('Emitted data event for TCP packet');
284
- }
285
- }
286
- }
287
- else {
288
- log.debug('Packet is not UDP or TCP (nextHeader !== 17 and !== 6)');
289
- }
193
+ this.writeDeviceFrameToTun(this.tun, frame.packet, frame.nextHeader);
290
194
  }
291
195
  catch (err) {
292
196
  log.error(`Error writing to TUN: ${err.message}`);
293
197
  }
294
- // Move to the next packet
295
- offset += 40 + payloadLength;
198
+ offset += frame.length;
296
199
  }
297
- // Keep any remaining partial data
298
200
  if (offset > 0) {
299
- this.buffer = this.buffer.slice(offset);
201
+ this.buffer = this.buffer.subarray(offset);
300
202
  }
301
203
  }
302
- startTunReadLoop(deviceConn) {
303
- this.readInterval = setInterval(() => {
304
- if (this.cancelled || !this.tun) {
305
- return;
204
+ writeDeviceFrameToTun(tun, packet, nextHeader) {
205
+ const bytesWritten = tun.write(packet);
206
+ if (!this.hasPacketTap()) {
207
+ log.debug(`Device → TUN: ${bytesWritten} bytes`);
208
+ return;
209
+ }
210
+ const { src, dst } = ipv6Endpoints(packet);
211
+ log.debug(`Device → TUN: ${bytesWritten} bytes, IPv6 src=${src}, dst=${dst}`);
212
+ this.tapL4Packet(packet, nextHeader, src, dst);
213
+ }
214
+ tapL4Packet(packet, nextHeader, src, dst) {
215
+ let packetData = null;
216
+ if (nextHeader === IPPROTO_UDP) {
217
+ packetData = parseUdpPacketData(packet, src, dst);
218
+ if (!packetData) {
219
+ log.debug('UDP payload too short, not emitting event.');
220
+ }
221
+ else {
222
+ log.debug(`UDP packet detected: payload length=${packetData.payload.length}`);
223
+ }
224
+ }
225
+ else if (nextHeader === IPPROTO_TCP) {
226
+ packetData = parseTcpPacketData(packet, src, dst);
227
+ if (!packetData) {
228
+ log.debug('TCP packet too short or malformed, skipping.');
306
229
  }
230
+ else {
231
+ log.debug(`TCP packet detected: payload length=${packetData.payload.length}`);
232
+ }
233
+ }
234
+ else {
235
+ log.debug('Packet is not UDP or TCP (nextHeader !== 17 and !== 6)');
236
+ }
237
+ if (packetData) {
238
+ this.dispatchPacketData(packetData);
239
+ }
240
+ }
241
+ dispatchPacketData(packetData) {
242
+ this.emit('data', packetData);
243
+ for (const consumer of this.packetConsumers) {
307
244
  try {
308
- // Read from TUN
309
- const data = this.tun.read(16384); // A large buffer for MTU
310
- // If we got data, send it to the device
311
- if (data && data.length > 0) {
312
- if (data.length >= 40) {
313
- // Minimum IPv6 header size
314
- log.debug(`TUN → Device: ${data.length} bytes, IPv6 src=${formatIPv6Address(data.slice(8, 24))}, dst=${formatIPv6Address(data.slice(24, 40))}`);
315
- }
316
- else {
317
- log.debug(`TUN → Device: ${data.length} bytes (too small for IPv6 header)`);
318
- }
319
- if (!deviceConn.destroyed) {
320
- deviceConn.write(data);
321
- }
322
- }
245
+ consumer.onPacket(packetData);
323
246
  }
324
247
  catch (err) {
325
- if (!this.cancelled) {
326
- log.error('Error reading from TUN:', err.message);
327
- }
248
+ log.error('Error in packet consumer:', err);
328
249
  }
329
- }, 5); // Poll every 5ms
250
+ }
251
+ log.debug(`Emitted data event for ${packetData.protocol} packet`);
252
+ }
253
+ startTunReadLoop(deviceConn) {
254
+ if (!this.tun) {
255
+ return;
256
+ }
257
+ this.tun.startPolling((data) => {
258
+ if (this.cancelled || !data.length || deviceConn.destroyed) {
259
+ return;
260
+ }
261
+ if (data.length >= IPV6_HEADER_SIZE) {
262
+ log.debug(`TUN → Device: ${data.length} bytes, IPv6 src=${formatIPv6Address(data.subarray(8, 24))}, dst=${formatIPv6Address(data.subarray(24, 40))}`);
263
+ }
264
+ else {
265
+ log.debug(`TUN → Device: ${data.length} bytes (too small for IPv6 header)`);
266
+ }
267
+ deviceConn.write(data);
268
+ }, this.mtu);
330
269
  }
331
270
  async _performStop() {
332
271
  const tunName = this.tun ? this.tun.name : 'unknown';
333
272
  log.debug(`Stopping tunnel manager for ${tunName}`);
334
273
  // Signal cancellation
335
274
  this.cancelled = true;
336
- // Clear read interval
337
- if (this.readInterval) {
338
- clearInterval(this.readInterval);
339
- this.readInterval = null;
340
- }
341
275
  // Close device connection if exists
342
276
  if (this.deviceConn && !this.deviceConn.destroyed) {
343
277
  this.deviceConn.destroy();
@@ -369,81 +303,14 @@ export class TunnelManager extends EventEmitter {
369
303
  * @returns parsed tunnel parameters from the device response
370
304
  */
371
305
  export async function exchangeCoreTunnelParameters(socket) {
372
- return new Promise((resolve, reject) => {
373
- const request = {
374
- type: 'clientHandshakeRequest',
375
- mtu: 16000,
376
- };
377
- const requestJSON = JSON.stringify(request);
378
- const jsonBuffer = Buffer.from(requestJSON);
379
- const magic = Buffer.from('CDTunnel');
380
- const length = Buffer.alloc(2);
381
- length.writeUInt16BE(jsonBuffer.length);
382
- const message = Buffer.concat([magic, length, jsonBuffer]);
383
- log.debug(`Sending CDTunnel packet: magic=${magic.toString()}, length=${jsonBuffer.length}, body=${requestJSON}`);
384
- socket.write(message);
385
- // For receiving the response
386
- let buffer = Buffer.alloc(0);
387
- function cleanup() {
388
- socket.removeListener('data', handleData);
389
- socket.removeListener('error', handleError);
390
- socket.removeListener('end', handleEnd);
391
- if (timeoutHandle) {
392
- clearTimeout(timeoutHandle);
393
- }
394
- }
395
- function handleData(data) {
396
- log.debug('Received data chunk:', data.length, 'bytes');
397
- buffer = Buffer.concat([buffer, data]);
398
- if (buffer.length < 10) {
399
- return;
400
- }
401
- const receivedMagic = buffer.slice(0, 8).toString();
402
- if (receivedMagic !== 'CDTunnel') {
403
- log.error('Invalid magic header:', receivedMagic);
404
- cleanup();
405
- return reject(new Error('Invalid packet format'));
406
- }
407
- const payloadLength = buffer.readUInt16BE(8);
408
- const totalLength = 8 + 2 + payloadLength;
409
- log.debug('Expected total packet length:', totalLength, 'current buffer:', buffer.length);
410
- if (buffer.length >= totalLength) {
411
- const payload = buffer.slice(10, totalLength);
412
- try {
413
- const response = JSON.parse(payload.toString());
414
- log.debug('Parsed CDTunnel response:', response);
415
- cleanup();
416
- resolve(response);
417
- }
418
- catch (err) {
419
- log.error('Failed to parse JSON:', err);
420
- cleanup();
421
- reject(new Error('Invalid JSON response'));
422
- }
423
- }
424
- }
425
- function handleError(err) {
426
- log.error('Socket error:', err);
427
- cleanup();
428
- reject(err);
429
- }
430
- function handleEnd() {
431
- log.debug('Connection ended');
432
- if (buffer.length > 0) {
433
- log.debug('Buffer at end:', buffer.toString('hex'));
434
- }
435
- cleanup();
436
- reject(new Error('Connection closed before receiving complete response'));
437
- }
438
- // Set a timeout for the handshake
439
- const timeoutHandle = setTimeout(() => {
440
- cleanup();
441
- reject(new Error('Tunnel handshake timeout'));
442
- }, 30000); // 30 second timeout
443
- socket.on('data', handleData);
444
- socket.on('error', handleError);
445
- socket.on('end', handleEnd);
306
+ const requestJson = JSON.stringify({
307
+ type: 'clientHandshakeRequest',
308
+ mtu: CD_TUNNEL_MTU,
446
309
  });
310
+ const message = encodeCdTunnelMessage(requestJson);
311
+ log.debug(`Sending CDTunnel packet: magic=${CD_TUNNEL_MAGIC}, length=${message.length - CD_TUNNEL_HEADER_SIZE}, body=${requestJson}`);
312
+ socket.write(message);
313
+ return readCdTunnelResponse(socket, CD_TUNNEL_HANDSHAKE_TIMEOUT_MS);
447
314
  }
448
315
  /**
449
316
  * End-to-end setup: handshake, TUN configuration, route, and forwarding on `secureServiceSocket`.
@@ -489,6 +356,144 @@ export async function connectToTunnelLockdown(secureServiceSocket) {
489
356
  throw err;
490
357
  }
491
358
  }
359
+ function encodeCdTunnelMessage(json) {
360
+ const body = Buffer.from(json);
361
+ const header = Buffer.alloc(CD_TUNNEL_HEADER_SIZE);
362
+ header.write(CD_TUNNEL_MAGIC, 0, CD_TUNNEL_MAGIC_SIZE, 'ascii');
363
+ header.writeUInt16BE(body.length, CD_TUNNEL_MAGIC_SIZE);
364
+ return Buffer.concat([header, body]);
365
+ }
366
+ function tryParseCdTunnelResponse(buffer) {
367
+ if (buffer.length < CD_TUNNEL_HEADER_SIZE) {
368
+ return { kind: 'incomplete' };
369
+ }
370
+ const magic = buffer.subarray(0, CD_TUNNEL_MAGIC_SIZE).toString();
371
+ if (magic !== CD_TUNNEL_MAGIC) {
372
+ log.error('Invalid magic header:', magic);
373
+ return { kind: 'error', error: new Error('Invalid packet format') };
374
+ }
375
+ const payloadLength = buffer.readUInt16BE(CD_TUNNEL_MAGIC_SIZE);
376
+ const totalLength = CD_TUNNEL_HEADER_SIZE + payloadLength;
377
+ if (buffer.length < totalLength) {
378
+ return { kind: 'incomplete' };
379
+ }
380
+ try {
381
+ const value = JSON.parse(buffer.subarray(CD_TUNNEL_HEADER_SIZE, totalLength).toString());
382
+ return { kind: 'ok', value };
383
+ }
384
+ catch (err) {
385
+ log.error('Failed to parse JSON:', err);
386
+ return { kind: 'error', error: new Error('Invalid JSON response') };
387
+ }
388
+ }
389
+ function readCdTunnelResponse(socket, timeoutMs) {
390
+ return new Promise((resolve, reject) => {
391
+ let buffer = Buffer.alloc(0);
392
+ const cleanup = () => {
393
+ socket.removeListener('data', onData);
394
+ socket.removeListener('error', onError);
395
+ socket.removeListener('end', onEnd);
396
+ clearTimeout(timeoutHandle);
397
+ };
398
+ const finish = (action) => {
399
+ cleanup();
400
+ action();
401
+ };
402
+ const onData = (chunk) => {
403
+ log.debug('Received data chunk:', chunk.length, 'bytes');
404
+ buffer = Buffer.concat([buffer, chunk]);
405
+ if (buffer.length >= CD_TUNNEL_HEADER_SIZE) {
406
+ const payloadLength = buffer.readUInt16BE(CD_TUNNEL_MAGIC_SIZE);
407
+ log.debug('Expected total packet length:', CD_TUNNEL_HEADER_SIZE + payloadLength, 'current buffer:', buffer.length);
408
+ }
409
+ const result = tryParseCdTunnelResponse(buffer);
410
+ if (result.kind === 'incomplete') {
411
+ return;
412
+ }
413
+ if (result.kind === 'error') {
414
+ finish(() => reject(result.error));
415
+ return;
416
+ }
417
+ log.debug('Parsed CDTunnel response:', result.value);
418
+ finish(() => resolve(result.value));
419
+ };
420
+ const onError = (err) => {
421
+ log.error('Socket error:', err);
422
+ finish(() => reject(err));
423
+ };
424
+ const onEnd = () => {
425
+ log.debug('Connection ended');
426
+ if (buffer.length > 0) {
427
+ log.debug('Buffer at end:', buffer.toString('hex'));
428
+ }
429
+ finish(() => reject(new Error('Connection closed before receiving complete response')));
430
+ };
431
+ const timeoutHandle = setTimeout(() => {
432
+ finish(() => reject(new Error('Tunnel handshake timeout')));
433
+ }, timeoutMs);
434
+ socket.on('data', onData);
435
+ socket.on('error', onError);
436
+ socket.on('end', onEnd);
437
+ });
438
+ }
439
+ function nextIpv6Frame(buffer, offset) {
440
+ if (offset + IPV6_HEADER_SIZE > buffer.length) {
441
+ return { kind: 'incomplete' };
442
+ }
443
+ const header = buffer.subarray(offset, offset + IPV6_HEADER_SIZE);
444
+ if (((header[0] >> 4) & 0x0f) !== IPV6_VERSION) {
445
+ return { kind: 'resync' };
446
+ }
447
+ const payloadLength = header.readUInt16BE(4);
448
+ const length = IPV6_HEADER_SIZE + payloadLength;
449
+ if (offset + length > buffer.length) {
450
+ return { kind: 'incomplete' };
451
+ }
452
+ return {
453
+ kind: 'frame',
454
+ packet: buffer.subarray(offset, offset + length),
455
+ nextHeader: header[6],
456
+ length,
457
+ };
458
+ }
459
+ function ipv6Endpoints(packet) {
460
+ return {
461
+ src: formatIPv6Address(packet.subarray(8, 24)),
462
+ dst: formatIPv6Address(packet.subarray(24, 40)),
463
+ };
464
+ }
465
+ function parseUdpPacketData(packet, src, dst) {
466
+ const payload = packet.subarray(IPV6_HEADER_SIZE);
467
+ if (payload.length < 8) {
468
+ return null;
469
+ }
470
+ return {
471
+ protocol: 'UDP',
472
+ src,
473
+ dst,
474
+ sourcePort: payload.readUInt16BE(0),
475
+ destPort: payload.readUInt16BE(2),
476
+ payload: payload.subarray(8),
477
+ };
478
+ }
479
+ function parseTcpPacketData(packet, src, dst) {
480
+ const tcpStart = IPV6_HEADER_SIZE;
481
+ if (packet.length < tcpStart + 20) {
482
+ return null;
483
+ }
484
+ const tcpHeaderLength = (packet.readUInt8(tcpStart + 12) >> 4) * 4;
485
+ if (packet.length < tcpStart + tcpHeaderLength) {
486
+ return null;
487
+ }
488
+ return {
489
+ protocol: 'TCP',
490
+ src,
491
+ dst,
492
+ sourcePort: packet.readUInt16BE(tcpStart),
493
+ destPort: packet.readUInt16BE(tcpStart + 2),
494
+ payload: packet.subarray(tcpStart + tcpHeaderLength),
495
+ };
496
+ }
492
497
  function formatIPv6Address(buffer) {
493
498
  if (!buffer || buffer.length !== 16) {
494
499
  return 'invalid-address';
@@ -0,0 +1,71 @@
1
+ import type { Buffer } from 'node:buffer';
2
+ import type { TunnelManager } from './manager.js';
3
+ export interface PacketData {
4
+ protocol: 'TCP' | 'UDP';
5
+ src: string;
6
+ dst: string;
7
+ sourcePort: number;
8
+ destPort: number;
9
+ payload: Buffer;
10
+ }
11
+ /**
12
+ * Event names and listener argument tuples for {@link TunnelManager}
13
+ * (matches Node’s `EventEmitter` event map shape).
14
+ *
15
+ * @example
16
+ * tunnelManager.on('data', (packet) => {
17
+ * // `packet` is PacketData
18
+ * });
19
+ */
20
+ export interface PacketConsumer {
21
+ /**
22
+ * Invoked for each parsed TCP/UDP payload extracted from the tunnel stream.
23
+ *
24
+ * @param packet — decoded addresses, ports, and payload
25
+ */
26
+ onPacket(packet: PacketData): void;
27
+ }
28
+ export interface TunnelManagerEvents {
29
+ data: [packet: PacketData];
30
+ }
31
+ export interface TunnelConnection {
32
+ Address: string;
33
+ RsdPort?: number;
34
+ tunnelManager: TunnelManager;
35
+ /** Tear down the tunnel, close the TUN device, and end the socket when appropriate. */
36
+ closer: () => Promise<void>;
37
+ /** @param consumer — receives packets for the lifetime of the registration */
38
+ addPacketConsumer(consumer: PacketConsumer): void;
39
+ /** @param consumer — must be the same reference passed to {@link TunnelConnection.addPacketConsumer} */
40
+ removePacketConsumer(consumer: PacketConsumer): void;
41
+ /** @returns async iterator of packets until the tunnel is stopped */
42
+ getPacketStream(): AsyncIterable<PacketData>;
43
+ }
44
+ export interface TunnelClientParameters {
45
+ address: string;
46
+ mtu: number;
47
+ }
48
+ export interface TunnelInfo {
49
+ clientParameters: TunnelClientParameters;
50
+ serverAddress: string;
51
+ serverRSDPort?: number;
52
+ }
53
+ export type Ipv6Frame = {
54
+ kind: 'frame';
55
+ packet: Buffer;
56
+ nextHeader: number;
57
+ length: number;
58
+ } | {
59
+ kind: 'incomplete';
60
+ } | {
61
+ kind: 'resync';
62
+ };
63
+ export type CdTunnelParseResult = {
64
+ kind: 'incomplete';
65
+ } | {
66
+ kind: 'ok';
67
+ value: TunnelInfo;
68
+ } | {
69
+ kind: 'error';
70
+ error: Error;
71
+ };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-tuntap",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Native TUN/TAP interface module for Node.js",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -49,7 +49,7 @@
49
49
  "license": "Apache-2.0",
50
50
  "gypfile": true,
51
51
  "dependencies": {
52
- "@appium/support": "^7.0.0-rc.1",
52
+ "@appium/support": "^7.2.0",
53
53
  "node-addon-api": "^8.5.0",
54
54
  "node-gyp-build": "^4.8.4",
55
55
  "typescript": "^6.0.2"
@@ -96,24 +96,27 @@ void PosixUvPollLoop::OnPoll(uv_poll_t* handle, int status, int events) {
96
96
  return;
97
97
  }
98
98
 
99
- std::vector<uint8_t> packet;
100
- std::string error;
101
- ReadPacketStatus rs = state->read_fn(state->buffer_size, packet, error);
102
-
103
- switch (rs) {
104
- case ReadPacketStatus::Data:
105
- if (state->on_packet) {
106
- state->on_packet(std::move(packet));
107
- }
108
- return;
109
- case ReadPacketStatus::NoData:
110
- return;
111
- case ReadPacketStatus::Closed:
112
- handle_terminal("Device closed");
113
- return;
114
- case ReadPacketStatus::Error:
115
- handle_terminal(error);
116
- return;
99
+ // Drain all readable packets before returning to libuv (match WinTun worker).
100
+ while (true) {
101
+ std::vector<uint8_t> packet;
102
+ std::string error;
103
+ ReadPacketStatus rs = state->read_fn(state->buffer_size, packet, error);
104
+
105
+ switch (rs) {
106
+ case ReadPacketStatus::Data:
107
+ if (state->on_packet) {
108
+ state->on_packet(std::move(packet));
109
+ }
110
+ break;
111
+ case ReadPacketStatus::NoData:
112
+ return;
113
+ case ReadPacketStatus::Closed:
114
+ handle_terminal("Device closed");
115
+ return;
116
+ case ReadPacketStatus::Error:
117
+ handle_terminal(error);
118
+ return;
119
+ }
117
120
  }
118
121
  }
119
122
 
@@ -125,12 +125,12 @@ public:
125
125
  return -1;
126
126
  }
127
127
 
128
- std::vector<uint8_t> frame(length + kUtunHeaderSize);
128
+ write_frame_.resize(length + kUtunHeaderSize);
129
129
  uint32_t family = htonl(AF_INET6);
130
- memcpy(frame.data(), &family, kUtunHeaderSize);
131
- memcpy(frame.data() + kUtunHeaderSize, data, length);
130
+ memcpy(write_frame_.data(), &family, kUtunHeaderSize);
131
+ memcpy(write_frame_.data() + kUtunHeaderSize, data, length);
132
132
 
133
- ssize_t bytes_written = write(fd_.get(), frame.data(), frame.size());
133
+ ssize_t bytes_written = write(fd_.get(), write_frame_.data(), write_frame_.size());
134
134
  if (bytes_written < 0) {
135
135
  error = std::string("Write error: ") + strerror(errno);
136
136
  return -1;
@@ -167,6 +167,8 @@ private:
167
167
  error = "Could not find an available utun device";
168
168
  return false;
169
169
  }
170
+
171
+ std::vector<uint8_t> write_frame_;
170
172
  };
171
173
 
172
174
  } // namespace
package/src/tuntap.cc CHANGED
@@ -227,11 +227,18 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
227
227
  Napi::ThreadSafeFunction tsfn = tsfn_;
228
228
  auto packet_cb = [tsfn](std::vector<uint8_t> packet) mutable {
229
229
  tsfn.BlockingCall(
230
- [packet = std::move(packet)](Napi::Env env, Napi::Function jsCallback) {
230
+ [packet = std::move(packet)](Napi::Env env, Napi::Function jsCallback) mutable {
231
231
  if (env == nullptr || jsCallback.IsEmpty()) {
232
232
  return;
233
233
  }
234
- jsCallback.Call({Napi::Buffer<uint8_t>::Copy(env, packet.data(), packet.size())});
234
+ auto* backing = new std::vector<uint8_t>(std::move(packet));
235
+ Napi::Buffer<uint8_t> buf = Napi::Buffer<uint8_t>::New(
236
+ env,
237
+ backing->data(),
238
+ backing->size(),
239
+ [](Napi::Env, uint8_t*, std::vector<uint8_t>* vec) { delete vec; },
240
+ backing);
241
+ jsCallback.Call({buf});
235
242
  });
236
243
  };
237
244
  // Terminal errors from the receive loop (poll error, device closed, read