appium-ios-tuntap 0.4.0 → 0.4.2

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.4.2](https://github.com/appium/appium-ios-tuntap/compare/v0.4.1...v0.4.2) (2026-06-01)
2
+
3
+ ### Miscellaneous Chores
4
+
5
+ * Tune further tunnel perf ([#46](https://github.com/appium/appium-ios-tuntap/issues/46)) ([d422937](https://github.com/appium/appium-ios-tuntap/commit/d422937a47b16bf1c984030772d1de6e555609e6))
6
+
7
+ ## [0.4.1](https://github.com/appium/appium-ios-tuntap/compare/v0.4.0...v0.4.1) (2026-05-31)
8
+
9
+ ### Bug Fixes
10
+
11
+ * Improve tunnel performance ([#45](https://github.com/appium/appium-ios-tuntap/issues/45)) ([576d353](https://github.com/appium/appium-ios-tuntap/commit/576d3535137bb0cf79634ab820b3675db6cbbb86))
12
+
1
13
  ## [0.4.0](https://github.com/appium/appium-ios-tuntap/compare/v0.3.0...v0.4.0) (2026-05-30)
2
14
 
3
15
  ### 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,4 @@
1
+ /**
2
+ * Append socket/TUN chunks without repeated Buffer.concat growth copies.
3
+ */
4
+ export declare function appendBuffer(existing: Buffer, chunk: Buffer): Buffer;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Append socket/TUN chunks without repeated Buffer.concat growth copies.
3
+ */
4
+ export function appendBuffer(existing, chunk) {
5
+ if (chunk.length === 0) {
6
+ return existing;
7
+ }
8
+ if (existing.length === 0) {
9
+ return Buffer.from(chunk);
10
+ }
11
+ const combined = Buffer.allocUnsafe(existing.length + chunk.length);
12
+ existing.copy(combined, 0);
13
+ chunk.copy(combined, existing.length);
14
+ return combined;
15
+ }
@@ -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,21 @@
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';
6
+ import { appendBuffer } from './buffer-utils.js';
5
7
  /**
6
8
  * Bridges a CoreDevice tunnel `Socket` and a {@link TunTap} interface: IPv6 framing, TUN I/O, and packet fan-out.
7
9
  * Emits {@link TunnelManagerEvents} (currently `data` with {@link PacketData}) for TCP/UDP packets, same as registered consumers.
8
10
  */
9
11
  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
- }
12
+ tun = null;
13
+ cancelled = false;
14
+ mtu = CD_TUNNEL_MTU;
15
+ buffer = Buffer.alloc(0);
16
+ packetConsumers = new Set();
17
+ deviceConn = null;
18
+ cleanupPromise = null;
30
19
  /**
31
20
  * Register a listener for parsed tunnel packets (in addition to the `data` event).
32
21
  *
@@ -99,6 +88,7 @@ export class TunnelManager extends EventEmitter {
99
88
  throw new Error('Failed to open TUN device');
100
89
  }
101
90
  log.debug(`Opened TUN device: ${this.tun.name}`);
91
+ this.mtu = tunnelInfo.clientParameters.mtu;
102
92
  // Configure the TUN device with IPv6 address and MTU
103
93
  await this.tun.configure(tunnelInfo.clientParameters.address, tunnelInfo.clientParameters.mtu);
104
94
  // Add route for the server address
@@ -136,14 +126,15 @@ export class TunnelManager extends EventEmitter {
136
126
  }
137
127
  this.deviceConn = deviceConn;
138
128
  log.debug(`Starting bidirectional data forwarding for ${this.tun.name}`);
129
+ deviceConn.setNoDelay(true);
130
+ deviceConn.setKeepAlive(true, 1000);
139
131
  // Handle data from the device connection
140
132
  deviceConn.on('data', (data) => {
141
133
  if (this.cancelled) {
142
134
  return;
143
135
  }
144
136
  try {
145
- // Add data to buffer
146
- this.buffer = Buffer.concat([this.buffer, data]);
137
+ this.buffer = appendBuffer(this.buffer, data);
147
138
  // Process IPv6 packets
148
139
  this.processBuffer();
149
140
  }
@@ -182,162 +173,107 @@ export class TunnelManager extends EventEmitter {
182
173
  this.cleanupPromise = this._performStop();
183
174
  return this.cleanupPromise;
184
175
  }
176
+ hasPacketTap() {
177
+ return this.packetConsumers.size > 0 || this.listenerCount('data') > 0;
178
+ }
185
179
  processBuffer() {
186
180
  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) {
181
+ while (offset + IPV6_HEADER_SIZE <= this.buffer.length) {
182
+ const frame = nextIpv6Frame(this.buffer, offset);
183
+ if (frame.kind === 'incomplete') {
184
+ break;
185
+ }
186
+ if (frame.kind === 'resync') {
194
187
  offset++;
195
188
  continue;
196
189
  }
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
190
+ if (!this.tun) {
191
+ log.error('TUN device is null during packet processing');
192
+ break;
202
193
  }
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
194
  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
- }
195
+ this.writeDeviceFrameToTun(this.tun, frame.packet, frame.nextHeader);
290
196
  }
291
197
  catch (err) {
292
198
  log.error(`Error writing to TUN: ${err.message}`);
293
199
  }
294
- // Move to the next packet
295
- offset += 40 + payloadLength;
200
+ offset += frame.length;
296
201
  }
297
- // Keep any remaining partial data
298
202
  if (offset > 0) {
299
- this.buffer = this.buffer.slice(offset);
203
+ this.buffer = this.buffer.subarray(offset);
300
204
  }
301
205
  }
302
- startTunReadLoop(deviceConn) {
303
- this.readInterval = setInterval(() => {
304
- if (this.cancelled || !this.tun) {
305
- return;
206
+ writeDeviceFrameToTun(tun, packet, nextHeader) {
207
+ const bytesWritten = tun.write(packet);
208
+ if (!this.hasPacketTap()) {
209
+ log.debug(`Device → TUN: ${bytesWritten} bytes`);
210
+ return;
211
+ }
212
+ const { src, dst } = ipv6Endpoints(packet);
213
+ log.debug(`Device → TUN: ${bytesWritten} bytes, IPv6 src=${src}, dst=${dst}`);
214
+ this.tapL4Packet(packet, nextHeader, src, dst);
215
+ }
216
+ tapL4Packet(packet, nextHeader, src, dst) {
217
+ let packetData = null;
218
+ if (nextHeader === IPPROTO_UDP) {
219
+ packetData = parseUdpPacketData(packet, src, dst);
220
+ if (!packetData) {
221
+ log.debug('UDP payload too short, not emitting event.');
222
+ }
223
+ else {
224
+ log.debug(`UDP packet detected: payload length=${packetData.payload.length}`);
225
+ }
226
+ }
227
+ else if (nextHeader === IPPROTO_TCP) {
228
+ packetData = parseTcpPacketData(packet, src, dst);
229
+ if (!packetData) {
230
+ log.debug('TCP packet too short or malformed, skipping.');
306
231
  }
232
+ else {
233
+ log.debug(`TCP packet detected: payload length=${packetData.payload.length}`);
234
+ }
235
+ }
236
+ else {
237
+ log.debug('Packet is not UDP or TCP (nextHeader !== 17 and !== 6)');
238
+ }
239
+ if (packetData) {
240
+ this.dispatchPacketData(packetData);
241
+ }
242
+ }
243
+ dispatchPacketData(packetData) {
244
+ this.emit('data', packetData);
245
+ for (const consumer of this.packetConsumers) {
307
246
  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
- }
247
+ consumer.onPacket(packetData);
323
248
  }
324
249
  catch (err) {
325
- if (!this.cancelled) {
326
- log.error('Error reading from TUN:', err.message);
327
- }
250
+ log.error('Error in packet consumer:', err);
328
251
  }
329
- }, 5); // Poll every 5ms
252
+ }
253
+ log.debug(`Emitted data event for ${packetData.protocol} packet`);
254
+ }
255
+ startTunReadLoop(deviceConn) {
256
+ if (!this.tun) {
257
+ return;
258
+ }
259
+ this.tun.startPolling((data) => {
260
+ if (this.cancelled || !data.length || deviceConn.destroyed) {
261
+ return;
262
+ }
263
+ if (this.hasPacketTap() && data.length >= IPV6_HEADER_SIZE) {
264
+ log.debug(`TUN → Device: ${data.length} bytes, IPv6 src=${formatIPv6Address(data.subarray(8, 24))}, dst=${formatIPv6Address(data.subarray(24, 40))}`);
265
+ }
266
+ else if (this.hasPacketTap()) {
267
+ log.debug(`TUN → Device: ${data.length} bytes (too small for IPv6 header)`);
268
+ }
269
+ deviceConn.write(data);
270
+ }, this.mtu);
330
271
  }
331
272
  async _performStop() {
332
273
  const tunName = this.tun ? this.tun.name : 'unknown';
333
274
  log.debug(`Stopping tunnel manager for ${tunName}`);
334
275
  // Signal cancellation
335
276
  this.cancelled = true;
336
- // Clear read interval
337
- if (this.readInterval) {
338
- clearInterval(this.readInterval);
339
- this.readInterval = null;
340
- }
341
277
  // Close device connection if exists
342
278
  if (this.deviceConn && !this.deviceConn.destroyed) {
343
279
  this.deviceConn.destroy();
@@ -369,81 +305,14 @@ export class TunnelManager extends EventEmitter {
369
305
  * @returns parsed tunnel parameters from the device response
370
306
  */
371
307
  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);
308
+ const requestJson = JSON.stringify({
309
+ type: 'clientHandshakeRequest',
310
+ mtu: CD_TUNNEL_MTU,
446
311
  });
312
+ const message = encodeCdTunnelMessage(requestJson);
313
+ log.debug(`Sending CDTunnel packet: magic=${CD_TUNNEL_MAGIC}, length=${message.length - CD_TUNNEL_HEADER_SIZE}, body=${requestJson}`);
314
+ socket.write(message);
315
+ return readCdTunnelResponse(socket, CD_TUNNEL_HANDSHAKE_TIMEOUT_MS);
447
316
  }
448
317
  /**
449
318
  * End-to-end setup: handshake, TUN configuration, route, and forwarding on `secureServiceSocket`.
@@ -489,6 +358,144 @@ export async function connectToTunnelLockdown(secureServiceSocket) {
489
358
  throw err;
490
359
  }
491
360
  }
361
+ function encodeCdTunnelMessage(json) {
362
+ const body = Buffer.from(json);
363
+ const header = Buffer.alloc(CD_TUNNEL_HEADER_SIZE);
364
+ header.write(CD_TUNNEL_MAGIC, 0, CD_TUNNEL_MAGIC_SIZE, 'ascii');
365
+ header.writeUInt16BE(body.length, CD_TUNNEL_MAGIC_SIZE);
366
+ return Buffer.concat([header, body]);
367
+ }
368
+ function tryParseCdTunnelResponse(buffer) {
369
+ if (buffer.length < CD_TUNNEL_HEADER_SIZE) {
370
+ return { kind: 'incomplete' };
371
+ }
372
+ const magic = buffer.subarray(0, CD_TUNNEL_MAGIC_SIZE).toString();
373
+ if (magic !== CD_TUNNEL_MAGIC) {
374
+ log.error('Invalid magic header:', magic);
375
+ return { kind: 'error', error: new Error('Invalid packet format') };
376
+ }
377
+ const payloadLength = buffer.readUInt16BE(CD_TUNNEL_MAGIC_SIZE);
378
+ const totalLength = CD_TUNNEL_HEADER_SIZE + payloadLength;
379
+ if (buffer.length < totalLength) {
380
+ return { kind: 'incomplete' };
381
+ }
382
+ try {
383
+ const value = JSON.parse(buffer.subarray(CD_TUNNEL_HEADER_SIZE, totalLength).toString());
384
+ return { kind: 'ok', value };
385
+ }
386
+ catch (err) {
387
+ log.error('Failed to parse JSON:', err);
388
+ return { kind: 'error', error: new Error('Invalid JSON response') };
389
+ }
390
+ }
391
+ function readCdTunnelResponse(socket, timeoutMs) {
392
+ return new Promise((resolve, reject) => {
393
+ let buffer = Buffer.alloc(0);
394
+ const cleanup = () => {
395
+ socket.removeListener('data', onData);
396
+ socket.removeListener('error', onError);
397
+ socket.removeListener('end', onEnd);
398
+ clearTimeout(timeoutHandle);
399
+ };
400
+ const finish = (action) => {
401
+ cleanup();
402
+ action();
403
+ };
404
+ const onData = (chunk) => {
405
+ log.debug('Received data chunk:', chunk.length, 'bytes');
406
+ buffer = appendBuffer(buffer, chunk);
407
+ if (buffer.length >= CD_TUNNEL_HEADER_SIZE) {
408
+ const payloadLength = buffer.readUInt16BE(CD_TUNNEL_MAGIC_SIZE);
409
+ log.debug('Expected total packet length:', CD_TUNNEL_HEADER_SIZE + payloadLength, 'current buffer:', buffer.length);
410
+ }
411
+ const result = tryParseCdTunnelResponse(buffer);
412
+ if (result.kind === 'incomplete') {
413
+ return;
414
+ }
415
+ if (result.kind === 'error') {
416
+ finish(() => reject(result.error));
417
+ return;
418
+ }
419
+ log.debug('Parsed CDTunnel response:', result.value);
420
+ finish(() => resolve(result.value));
421
+ };
422
+ const onError = (err) => {
423
+ log.error('Socket error:', err);
424
+ finish(() => reject(err));
425
+ };
426
+ const onEnd = () => {
427
+ log.debug('Connection ended');
428
+ if (buffer.length > 0) {
429
+ log.debug('Buffer at end:', buffer.toString('hex'));
430
+ }
431
+ finish(() => reject(new Error('Connection closed before receiving complete response')));
432
+ };
433
+ const timeoutHandle = setTimeout(() => {
434
+ finish(() => reject(new Error('Tunnel handshake timeout')));
435
+ }, timeoutMs);
436
+ socket.on('data', onData);
437
+ socket.on('error', onError);
438
+ socket.on('end', onEnd);
439
+ });
440
+ }
441
+ function nextIpv6Frame(buffer, offset) {
442
+ if (offset + IPV6_HEADER_SIZE > buffer.length) {
443
+ return { kind: 'incomplete' };
444
+ }
445
+ const header = buffer.subarray(offset, offset + IPV6_HEADER_SIZE);
446
+ if (((header[0] >> 4) & 0x0f) !== IPV6_VERSION) {
447
+ return { kind: 'resync' };
448
+ }
449
+ const payloadLength = header.readUInt16BE(4);
450
+ const length = IPV6_HEADER_SIZE + payloadLength;
451
+ if (offset + length > buffer.length) {
452
+ return { kind: 'incomplete' };
453
+ }
454
+ return {
455
+ kind: 'frame',
456
+ packet: buffer.subarray(offset, offset + length),
457
+ nextHeader: header[6],
458
+ length,
459
+ };
460
+ }
461
+ function ipv6Endpoints(packet) {
462
+ return {
463
+ src: formatIPv6Address(packet.subarray(8, 24)),
464
+ dst: formatIPv6Address(packet.subarray(24, 40)),
465
+ };
466
+ }
467
+ function parseUdpPacketData(packet, src, dst) {
468
+ const payload = packet.subarray(IPV6_HEADER_SIZE);
469
+ if (payload.length < 8) {
470
+ return null;
471
+ }
472
+ return {
473
+ protocol: 'UDP',
474
+ src,
475
+ dst,
476
+ sourcePort: payload.readUInt16BE(0),
477
+ destPort: payload.readUInt16BE(2),
478
+ payload: payload.subarray(8),
479
+ };
480
+ }
481
+ function parseTcpPacketData(packet, src, dst) {
482
+ const tcpStart = IPV6_HEADER_SIZE;
483
+ if (packet.length < tcpStart + 20) {
484
+ return null;
485
+ }
486
+ const tcpHeaderLength = (packet.readUInt8(tcpStart + 12) >> 4) * 4;
487
+ if (packet.length < tcpStart + tcpHeaderLength) {
488
+ return null;
489
+ }
490
+ return {
491
+ protocol: 'TCP',
492
+ src,
493
+ dst,
494
+ sourcePort: packet.readUInt16BE(tcpStart),
495
+ destPort: packet.readUInt16BE(tcpStart + 2),
496
+ payload: packet.subarray(tcpStart + tcpHeaderLength),
497
+ };
498
+ }
492
499
  function formatIPv6Address(buffer) {
493
500
  if (!buffer || buffer.length !== 16) {
494
501
  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.2",
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
 
@@ -91,8 +91,11 @@ public:
91
91
  return ReadPacketStatus::Error;
92
92
  }
93
93
 
94
- out.resize(max_payload_size + kUtunHeaderSize);
95
- ssize_t bytes_read = read(fd_.get(), out.data(), out.size());
94
+ const size_t read_cap = max_payload_size + kUtunHeaderSize;
95
+ if (read_frame_.size() < read_cap) {
96
+ read_frame_.resize(read_cap);
97
+ }
98
+ ssize_t bytes_read = read(fd_.get(), read_frame_.data(), read_cap);
96
99
  if (bytes_read < 0) {
97
100
  if (errno == EAGAIN || errno == EWOULDBLOCK) {
98
101
  out.clear();
@@ -111,9 +114,8 @@ public:
111
114
  }
112
115
 
113
116
  const auto payload_len = static_cast<size_t>(bytes_read - kUtunHeaderSize);
114
- // Collapse the utun 4-byte address-family prefix in-place.
115
- memmove(out.data(), out.data() + kUtunHeaderSize, payload_len);
116
117
  out.resize(payload_len);
118
+ memcpy(out.data(), read_frame_.data() + kUtunHeaderSize, payload_len);
117
119
  return ReadPacketStatus::Data;
118
120
  }
119
121
 
@@ -125,12 +127,12 @@ public:
125
127
  return -1;
126
128
  }
127
129
 
128
- std::vector<uint8_t> frame(length + kUtunHeaderSize);
130
+ write_frame_.resize(length + kUtunHeaderSize);
129
131
  uint32_t family = htonl(AF_INET6);
130
- memcpy(frame.data(), &family, kUtunHeaderSize);
131
- memcpy(frame.data() + kUtunHeaderSize, data, length);
132
+ memcpy(write_frame_.data(), &family, kUtunHeaderSize);
133
+ memcpy(write_frame_.data() + kUtunHeaderSize, data, length);
132
134
 
133
- ssize_t bytes_written = write(fd_.get(), frame.data(), frame.size());
135
+ ssize_t bytes_written = write(fd_.get(), write_frame_.data(), write_frame_.size());
134
136
  if (bytes_written < 0) {
135
137
  error = std::string("Write error: ") + strerror(errno);
136
138
  return -1;
@@ -167,6 +169,9 @@ private:
167
169
  error = "Could not find an available utun device";
168
170
  return false;
169
171
  }
172
+
173
+ std::vector<uint8_t> read_frame_;
174
+ std::vector<uint8_t> write_frame_;
170
175
  };
171
176
 
172
177
  } // namespace
package/src/tuntap.cc CHANGED
@@ -209,12 +209,14 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
209
209
  buffer_size = size;
210
210
  }
211
211
 
212
+ // Queue depth > 1 lets the poll thread post the next packet while JS is still
213
+ // handling the previous callback (still serialized on the main thread).
212
214
  tsfn_ = Napi::ThreadSafeFunction::New(
213
215
  env,
214
216
  info[0].As<Napi::Function>(),
215
217
  "TunDeviceDataCallback",
216
218
  0,
217
- 1);
219
+ 8);
218
220
 
219
221
  uv_loop_t* loop = nullptr;
220
222
  napi_status napi_st = napi_get_uv_event_loop(env, &loop);
@@ -227,11 +229,18 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
227
229
  Napi::ThreadSafeFunction tsfn = tsfn_;
228
230
  auto packet_cb = [tsfn](std::vector<uint8_t> packet) mutable {
229
231
  tsfn.BlockingCall(
230
- [packet = std::move(packet)](Napi::Env env, Napi::Function jsCallback) {
232
+ [packet = std::move(packet)](Napi::Env env, Napi::Function jsCallback) mutable {
231
233
  if (env == nullptr || jsCallback.IsEmpty()) {
232
234
  return;
233
235
  }
234
- jsCallback.Call({Napi::Buffer<uint8_t>::Copy(env, packet.data(), packet.size())});
236
+ auto* backing = new std::vector<uint8_t>(std::move(packet));
237
+ Napi::Buffer<uint8_t> buf = Napi::Buffer<uint8_t>::New(
238
+ env,
239
+ backing->data(),
240
+ backing->size(),
241
+ [](Napi::Env, uint8_t*, std::vector<uint8_t>* vec) { delete vec; },
242
+ backing);
243
+ jsCallback.Call({buf});
235
244
  });
236
245
  };
237
246
  // Terminal errors from the receive loop (poll error, device closed, read