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 +6 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/tunnel/constants.d.ts +10 -0
- package/lib/tunnel/constants.js +10 -0
- package/lib/tunnel/index.d.ts +2 -0
- package/lib/tunnel/index.js +1 -0
- package/lib/{tunnel.d.ts → tunnel/manager.d.ts} +8 -58
- package/lib/{tunnel.js → tunnel/manager.js} +231 -226
- package/lib/tunnel/types.d.ts +71 -0
- package/lib/tunnel/types.js +1 -0
- package/package.json +2 -2
- package/prebuilds/darwin-arm64/appium-ios-tuntap.node +0 -0
- package/prebuilds/darwin-x64/appium-ios-tuntap.node +0 -0
- package/prebuilds/linux-arm64/appium-ios-tuntap.node +0 -0
- package/prebuilds/linux-x64/appium-ios-tuntap.node +0 -0
- package/prebuilds/win32-arm64/appium-ios-tuntap.node +0 -0
- package/prebuilds/win32-x64/appium-ios-tuntap.node +0 -0
- package/src/native/posix_uv_poll_loop.cc +21 -18
- package/src/native/tun_backend_darwin.cc +6 -4
- package/src/tuntap.cc +9 -2
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
package/lib/index.js
CHANGED
|
@@ -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 @@
|
|
|
1
|
+
export { TunnelManager, connectToTunnelLockdown, exchangeCoreTunnelParameters } from './manager.js';
|
|
@@ -1,57 +1,7 @@
|
|
|
1
|
-
import { TunTap } from '
|
|
1
|
+
import { TunTap } from '../TunTap.js';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import type { Socket } from 'node:net';
|
|
4
|
-
import {
|
|
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
|
|
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 '
|
|
2
|
-
import { TunTap } from '
|
|
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
|
-
|
|
13
|
-
buffer;
|
|
14
|
-
packetConsumers;
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
201
|
+
this.buffer = this.buffer.subarray(offset);
|
|
300
202
|
}
|
|
301
203
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
log.error('Error reading from TUN:', err.message);
|
|
327
|
-
}
|
|
248
|
+
log.error('Error in packet consumer:', err);
|
|
328
249
|
}
|
|
329
|
-
}
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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.
|
|
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.
|
|
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"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -96,24 +96,27 @@ void PosixUvPollLoop::OnPoll(uv_poll_t* handle, int status, int events) {
|
|
|
96
96
|
return;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
128
|
+
write_frame_.resize(length + kUtunHeaderSize);
|
|
129
129
|
uint32_t family = htonl(AF_INET6);
|
|
130
|
-
memcpy(
|
|
131
|
-
memcpy(
|
|
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(),
|
|
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
|
-
|
|
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
|