appium-ios-tuntap 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.1.8](https://github.com/appium/appium-ios-tuntap/compare/v0.1.7...v0.1.8) (2026-04-12)
2
+
3
+ ### Code Refactoring
4
+
5
+ * isolate OS networking in TunTapPlatform ([#27](https://github.com/appium/appium-ios-tuntap/issues/27)) ([7de387f](https://github.com/appium/appium-ios-tuntap/commit/7de387f0e4c2a07b81056f8da95f69cf34809cef))
6
+
7
+ ## [0.1.7](https://github.com/appium/appium-ios-tuntap/compare/v0.1.6...v0.1.7) (2026-04-11)
8
+
9
+ ### Miscellaneous Chores
10
+
11
+ * **deps:** bump typescript from 5.9.3 to 6.0.2 ([#22](https://github.com/appium/appium-ios-tuntap/issues/22)) ([07c2620](https://github.com/appium/appium-ios-tuntap/commit/07c2620f5743d59fee236f6e835045047b91c7f8))
12
+
1
13
  ## [0.1.6](https://github.com/appium/appium-ios-tuntap/compare/v0.1.5...v0.1.6) (2026-04-11)
2
14
 
3
15
  ### Code Refactoring
Binary file
package/build/config.gypi CHANGED
@@ -502,7 +502,7 @@
502
502
  "cache": "/Users/runner/.npm",
503
503
  "node_gyp": "/Users/runner/work/appium-ios-tuntap/appium-ios-tuntap/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js",
504
504
  "npm_version": "11.11.0",
505
- "userconfig": "/private/var/folders/tb/y368xp_x10s3ty1b_mtl5mxr0000gn/T/8df09367f00966ee4ef2275480f452a9/.npmrc",
505
+ "userconfig": "/private/var/folders/tb/y368xp_x10s3ty1b_mtl5mxr0000gn/T/dae56aff1d9f6176e257d9aa15256f4d/.npmrc",
506
506
  "init_module": "/Users/runner/.npm-init.js",
507
507
  "globalconfig": "/Users/runner/hostedtoolcache/node/24.14.1/arm64/etc/npmrc",
508
508
  "local_prefix": "/Users/runner/work/appium-ios-tuntap/appium-ios-tuntap",
package/lib/TunTap.d.ts CHANGED
@@ -1,56 +1,114 @@
1
+ import type { TunTapInterfaceStats } from './platform/types.js';
2
+ /**
3
+ * Called by {@link TunTap.startPolling} for each packet read from the TUN device.
4
+ *
5
+ * @param data — raw L3 frame (IPv6) read from the device
6
+ */
1
7
  export type PacketCallback = (data: Buffer) => void;
2
- export declare class TunTapError extends Error {
3
- code?: string | undefined;
4
- constructor(message: string, code?: string | undefined);
5
- }
6
- export declare class TunTapPermissionError extends TunTapError {
7
- constructor(message: string);
8
- }
9
- export declare class TunTapDeviceError extends TunTapError {
10
- constructor(message: string);
11
- }
12
8
  /**
13
- * TUN/TAP device for IP tunneling
9
+ * High-level wrapper around the native TUN device with IPv6-only configuration helpers.
10
+ *
11
+ * Routing and addressing use a built-in OS backend chosen from the `platform` argument (requires root, EUID 0 on Darwin/Linux).
14
12
  */
15
13
  export declare class TunTap {
16
14
  private device;
15
+ private readonly platformBackend;
17
16
  private _isOpen;
18
17
  private _isClosed;
19
18
  private removeExitListener;
20
- constructor(name?: string);
19
+ /**
20
+ * @param name — optional interface name hint for the native layer
21
+ * @param platform — Node.js platform id (e.g. `darwin`, `linux`); defaults to `process.platform`
22
+ */
23
+ constructor(name?: string, platform?: NodeJS.Platform);
24
+ /** Whether {@link TunTap.open} has succeeded and {@link TunTap.close} has not run. */
21
25
  get isOpen(): boolean;
26
+ /** Whether {@link TunTap.close} has been called (device cannot be reopened). */
22
27
  get isClosed(): boolean;
23
28
  /**
24
- * Throws if the device is not in a usable state (not open or already closed).
29
+ * Open the TUN device via the native addon.
30
+ *
31
+ * @returns `true` when the device is open
32
+ * @throws {TunTapError} if already closed
33
+ * @throws {TunTapDeviceError} if the device cannot be opened
34
+ * @throws {TunTapPermissionError} if the OS denies access
25
35
  */
26
- private assertReady;
27
36
  open(): boolean;
37
+ /**
38
+ * Close the device and unregister process `exit` cleanup.
39
+ *
40
+ * @returns `true` when closed (idempotent)
41
+ * @throws {TunTapError} on native close failure
42
+ */
28
43
  close(): boolean;
44
+ /**
45
+ * Read one datagram/packet from the TUN device (blocking in the native layer).
46
+ *
47
+ * @param maxSize — upper bound on bytes to read (default 4096)
48
+ * @returns packet buffer
49
+ * @throws {TunTapError} if not open, closed, or read fails
50
+ * @throws {RangeError} if `maxSize` is out of range
51
+ */
29
52
  read(maxSize?: number): Buffer;
53
+ /**
54
+ * Write a full IPv6 packet to the TUN device.
55
+ *
56
+ * @param data — L3 payload to write
57
+ * @returns number of bytes written
58
+ * @throws {TunTapError} if not open, closed, or write fails
59
+ * @throws {TypeError} if `data` is not a `Buffer`
60
+ * @throws {RangeError} if `data` exceeds the maximum buffer size
61
+ */
30
62
  write(data: Buffer): number;
31
63
  /**
32
- * Start event-driven reading from the TUN device.
33
- * The callback is invoked with each packet read from the device.
64
+ * Start libuv-driven polling on the TUN fd; `callback` runs on the Node thread pool per packet.
65
+ *
66
+ * @param callback — invoked with each packet read from the device
67
+ * @param bufferSize — max read size per poll (default 65535)
68
+ * @throws {TunTapError} if not open or closed
69
+ * @throws {TypeError} if `callback` is not a function
70
+ * @throws {RangeError} if `bufferSize` is out of range
34
71
  */
35
72
  startPolling(callback: PacketCallback, bufferSize?: number): void;
73
+ /** OS-assigned interface name (e.g. `utun4` on macOS). */
36
74
  get name(): string;
75
+ /** File descriptor for the open TUN device (for advanced use). */
37
76
  get fd(): number;
77
+ /**
78
+ * Configure IPv6 address and MTU on this interface using the platform backend (must run as root on Darwin/Linux).
79
+ *
80
+ * @param address — IPv6 address
81
+ * @param mtu — link MTU (min 1280, max 65535)
82
+ * @throws {TypeError} if `address` is not a valid IPv6 literal
83
+ * @throws {RangeError} if `mtu` is out of range
84
+ * @throws {TunTapError} on backend failure
85
+ */
38
86
  configure(address: string, mtu?: number): Promise<void>;
39
- private configureLinux;
87
+ /**
88
+ * Add an IPv6 route via this TUN interface.
89
+ *
90
+ * @param destination — IPv6 address or CIDR (e.g. `fd00::/64`)
91
+ * @throws {TypeError} if `destination` is invalid
92
+ * @throws {TunTapError} on backend failure
93
+ */
40
94
  addRoute(destination: string): Promise<void>;
41
- private addRouteLinux;
95
+ /**
96
+ * Remove an IPv6 route previously added for this interface.
97
+ *
98
+ * @param destination — same form as {@link TunTap.addRoute}
99
+ * @throws {TypeError} if `destination` is invalid
100
+ * @throws {TunTapError} if the backend reports an error other than “route missing”
101
+ */
42
102
  removeRoute(destination: string): Promise<void>;
43
103
  /**
44
- * Get interface statistics.
45
- */
46
- getStats(): Promise<{
47
- rxBytes: number;
48
- txBytes: number;
49
- rxPackets: number;
50
- txPackets: number;
51
- rxErrors: number;
52
- txErrors: number;
53
- }>;
54
- private getStatsDarwin;
55
- private getStatsLinux;
104
+ * Fetch RX/TX counters for this interface from the OS (e.g. `netstat` / `ip -s`).
105
+ *
106
+ * @returns byte and packet counts plus error counters
107
+ * @throws {TunTapError} if not ready or stats cannot be parsed
108
+ */
109
+ getStats(): Promise<TunTapInterfaceStats>;
110
+ /**
111
+ * Throws if the device is not in a usable state (not open or already closed).
112
+ */
113
+ private assertReady;
56
114
  }
package/lib/TunTap.js CHANGED
@@ -1,37 +1,14 @@
1
- import { execFile } from 'node:child_process';
2
1
  import { createRequire } from 'node:module';
3
2
  import { isIPv6 } from 'node:net';
4
- import { promisify } from 'node:util';
3
+ import { TunTapDeviceError, TunTapError, TunTapPermissionError, } from './errors.js';
5
4
  import { log } from './logger.js';
5
+ import { createTunTapPlatform } from './platform/create-platform.js';
6
6
  const require = createRequire(import.meta.url);
7
- const execFileAsync = promisify(execFile);
8
- const PLATFORM = process.platform;
9
7
  const DEFAULT_READ_BUFFER_SIZE = 4096;
10
8
  const MAX_BUFFER_SIZE = 0xFFFF; // 65535
11
9
  const DEFAULT_MTU = 1500;
12
10
  const MIN_MTU = 1280;
13
11
  const nativeTuntap = require('../build/Release/tuntap.node');
14
- // Custom error types
15
- export class TunTapError extends Error {
16
- code;
17
- constructor(message, code) {
18
- super(message);
19
- this.code = code;
20
- this.name = 'TunTapError';
21
- }
22
- }
23
- export class TunTapPermissionError extends TunTapError {
24
- constructor(message) {
25
- super(message, 'EPERM');
26
- this.name = 'TunTapPermissionError';
27
- }
28
- }
29
- export class TunTapDeviceError extends TunTapError {
30
- constructor(message) {
31
- super(message, 'ENODEV');
32
- this.name = 'TunTapDeviceError';
33
- }
34
- }
35
12
  /**
36
13
  * Validates an IPv6 route destination (address with optional CIDR prefix).
37
14
  */
@@ -49,15 +26,23 @@ function isValidIPv6Route(destination) {
49
26
  return isIPv6(parts[0]);
50
27
  }
51
28
  /**
52
- * TUN/TAP device for IP tunneling
29
+ * High-level wrapper around the native TUN device with IPv6-only configuration helpers.
30
+ *
31
+ * Routing and addressing use a built-in OS backend chosen from the `platform` argument (requires root, EUID 0 on Darwin/Linux).
53
32
  */
54
33
  export class TunTap {
55
34
  device;
35
+ platformBackend;
56
36
  _isOpen;
57
37
  _isClosed;
58
38
  removeExitListener = null;
59
- constructor(name = '') {
39
+ /**
40
+ * @param name — optional interface name hint for the native layer
41
+ * @param platform — Node.js platform id (e.g. `darwin`, `linux`); defaults to `process.platform`
42
+ */
43
+ constructor(name = '', platform = process.platform) {
60
44
  this.device = new nativeTuntap.TunDevice(name);
45
+ this.platformBackend = createTunTapPlatform(platform);
61
46
  this._isOpen = false;
62
47
  this._isClosed = false;
63
48
  // Register cleanup on process exit only.
@@ -78,23 +63,22 @@ export class TunTap {
78
63
  process.removeListener('exit', cleanup);
79
64
  };
80
65
  }
66
+ /** Whether {@link TunTap.open} has succeeded and {@link TunTap.close} has not run. */
81
67
  get isOpen() {
82
68
  return this._isOpen;
83
69
  }
70
+ /** Whether {@link TunTap.close} has been called (device cannot be reopened). */
84
71
  get isClosed() {
85
72
  return this._isClosed;
86
73
  }
87
74
  /**
88
- * Throws if the device is not in a usable state (not open or already closed).
75
+ * Open the TUN device via the native addon.
76
+ *
77
+ * @returns `true` when the device is open
78
+ * @throws {TunTapError} if already closed
79
+ * @throws {TunTapDeviceError} if the device cannot be opened
80
+ * @throws {TunTapPermissionError} if the OS denies access
89
81
  */
90
- assertReady() {
91
- if (!this._isOpen) {
92
- throw new TunTapError('Device not open');
93
- }
94
- if (this._isClosed) {
95
- throw new TunTapError('Device has been closed');
96
- }
97
- }
98
82
  open() {
99
83
  if (this._isClosed) {
100
84
  throw new TunTapError('Device has been closed and cannot be reopened');
@@ -119,6 +103,12 @@ export class TunTap {
119
103
  }
120
104
  return this._isOpen;
121
105
  }
106
+ /**
107
+ * Close the device and unregister process `exit` cleanup.
108
+ *
109
+ * @returns `true` when closed (idempotent)
110
+ * @throws {TunTapError} on native close failure
111
+ */
122
112
  close() {
123
113
  if (this.removeExitListener) {
124
114
  this.removeExitListener();
@@ -138,6 +128,14 @@ export class TunTap {
138
128
  }
139
129
  return true;
140
130
  }
131
+ /**
132
+ * Read one datagram/packet from the TUN device (blocking in the native layer).
133
+ *
134
+ * @param maxSize — upper bound on bytes to read (default 4096)
135
+ * @returns packet buffer
136
+ * @throws {TunTapError} if not open, closed, or read fails
137
+ * @throws {RangeError} if `maxSize` is out of range
138
+ */
141
139
  read(maxSize = DEFAULT_READ_BUFFER_SIZE) {
142
140
  this.assertReady();
143
141
  if (maxSize <= 0 || maxSize > MAX_BUFFER_SIZE) {
@@ -150,6 +148,15 @@ export class TunTap {
150
148
  throw new TunTapError(`Read failed: ${err.message}`);
151
149
  }
152
150
  }
151
+ /**
152
+ * Write a full IPv6 packet to the TUN device.
153
+ *
154
+ * @param data — L3 payload to write
155
+ * @returns number of bytes written
156
+ * @throws {TunTapError} if not open, closed, or write fails
157
+ * @throws {TypeError} if `data` is not a `Buffer`
158
+ * @throws {RangeError} if `data` exceeds the maximum buffer size
159
+ */
153
160
  write(data) {
154
161
  this.assertReady();
155
162
  if (!Buffer.isBuffer(data)) {
@@ -173,8 +180,13 @@ export class TunTap {
173
180
  }
174
181
  }
175
182
  /**
176
- * Start event-driven reading from the TUN device.
177
- * The callback is invoked with each packet read from the device.
183
+ * Start libuv-driven polling on the TUN fd; `callback` runs on the Node thread pool per packet.
184
+ *
185
+ * @param callback — invoked with each packet read from the device
186
+ * @param bufferSize — max read size per poll (default 65535)
187
+ * @throws {TunTapError} if not open or closed
188
+ * @throws {TypeError} if `callback` is not a function
189
+ * @throws {RangeError} if `bufferSize` is out of range
178
190
  */
179
191
  startPolling(callback, bufferSize = MAX_BUFFER_SIZE) {
180
192
  this.assertReady();
@@ -186,12 +198,23 @@ export class TunTap {
186
198
  }
187
199
  this.device.startPolling(callback, bufferSize);
188
200
  }
201
+ /** OS-assigned interface name (e.g. `utun4` on macOS). */
189
202
  get name() {
190
203
  return this.device.getName();
191
204
  }
205
+ /** File descriptor for the open TUN device (for advanced use). */
192
206
  get fd() {
193
207
  return this.device.getFd();
194
208
  }
209
+ /**
210
+ * Configure IPv6 address and MTU on this interface using the platform backend (must run as root on Darwin/Linux).
211
+ *
212
+ * @param address — IPv6 address
213
+ * @param mtu — link MTU (min 1280, max 65535)
214
+ * @throws {TypeError} if `address` is not a valid IPv6 literal
215
+ * @throws {RangeError} if `mtu` is out of range
216
+ * @throws {TunTapError} on backend failure
217
+ */
195
218
  async configure(address, mtu = DEFAULT_MTU) {
196
219
  this.assertReady();
197
220
  if (!isIPv6(address)) {
@@ -201,16 +224,7 @@ export class TunTap {
201
224
  throw new RangeError(`MTU must be between ${MIN_MTU} and ${MAX_BUFFER_SIZE}`);
202
225
  }
203
226
  try {
204
- if (PLATFORM === 'darwin') {
205
- await execFileAsync('sudo', ['ifconfig', this.name, 'inet6', address, 'prefixlen', '64', 'up']);
206
- await execFileAsync('sudo', ['ifconfig', this.name, 'mtu', String(mtu)]);
207
- }
208
- else if (PLATFORM === 'linux') {
209
- await this.configureLinux(address, mtu);
210
- }
211
- else {
212
- throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
213
- }
227
+ await this.platformBackend.configure(this.name, address, mtu);
214
228
  }
215
229
  catch (err) {
216
230
  if (err instanceof TunTapError) {
@@ -219,28 +233,13 @@ export class TunTap {
219
233
  throw new TunTapError(`Failed to configure TUN interface: ${err.message}`);
220
234
  }
221
235
  }
222
- async configureLinux(address, mtu) {
223
- try {
224
- await execFileAsync('which', ['ip']);
225
- }
226
- catch {
227
- throw new TunTapError('The "ip" command is not available. Please install iproute2 (e.g., sudo apt install iproute2)');
228
- }
229
- try {
230
- await execFileAsync('sudo', ['ip', '-6', 'addr', 'add', `${address}/64`, 'dev', this.name]);
231
- }
232
- catch (err) {
233
- const message = err.message;
234
- if (message.includes('Permission denied')) {
235
- throw new TunTapPermissionError('Permission denied when configuring network interface. Run with sudo.');
236
- }
237
- if (!message.includes('File exists')) {
238
- throw err;
239
- }
240
- log.warn(`Address ${address} may already be configured on ${this.name}`);
241
- }
242
- await execFileAsync('sudo', ['ip', 'link', 'set', 'dev', this.name, 'up', 'mtu', String(mtu)]);
243
- }
236
+ /**
237
+ * Add an IPv6 route via this TUN interface.
238
+ *
239
+ * @param destination — IPv6 address or CIDR (e.g. `fd00::/64`)
240
+ * @throws {TypeError} if `destination` is invalid
241
+ * @throws {TunTapError} on backend failure
242
+ */
244
243
  async addRoute(destination) {
245
244
  this.assertReady();
246
245
  if (!destination || typeof destination !== 'string') {
@@ -250,15 +249,7 @@ export class TunTap {
250
249
  throw new TypeError('Destination must be a valid IPv6 address or CIDR (e.g., fd00::1/128)');
251
250
  }
252
251
  try {
253
- if (PLATFORM === 'darwin') {
254
- await execFileAsync('sudo', ['route', '-n', 'add', '-inet6', destination, '-interface', this.name]);
255
- }
256
- else if (PLATFORM === 'linux') {
257
- await this.addRouteLinux(destination);
258
- }
259
- else {
260
- throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
261
- }
252
+ await this.platformBackend.addRoute(this.name, destination);
262
253
  }
263
254
  catch (err) {
264
255
  if (err instanceof TunTapError) {
@@ -267,22 +258,13 @@ export class TunTap {
267
258
  throw new TunTapError(`Failed to add route: ${err.message}`);
268
259
  }
269
260
  }
270
- async addRouteLinux(destination) {
271
- try {
272
- await execFileAsync('sudo', ['ip', '-6', 'route', 'add', destination, 'dev', this.name]);
273
- }
274
- catch (err) {
275
- const message = err.message;
276
- if (message.includes('Permission denied')) {
277
- throw new TunTapPermissionError('Permission denied when adding route. Run with sudo.');
278
- }
279
- if (message.includes('File exists')) {
280
- log.info(`Route to ${destination} already exists`);
281
- return;
282
- }
283
- throw err;
284
- }
285
- }
261
+ /**
262
+ * Remove an IPv6 route previously added for this interface.
263
+ *
264
+ * @param destination — same form as {@link TunTap.addRoute}
265
+ * @throws {TypeError} if `destination` is invalid
266
+ * @throws {TunTapError} if the backend reports an error other than “route missing”
267
+ */
286
268
  async removeRoute(destination) {
287
269
  this.assertReady();
288
270
  if (!destination || typeof destination !== 'string') {
@@ -292,15 +274,7 @@ export class TunTap {
292
274
  throw new TypeError('Destination must be a valid IPv6 address or CIDR');
293
275
  }
294
276
  try {
295
- if (PLATFORM === 'darwin') {
296
- await execFileAsync('sudo', ['route', '-n', 'delete', '-inet6', destination]);
297
- }
298
- else if (PLATFORM === 'linux') {
299
- await execFileAsync('sudo', ['ip', '-6', 'route', 'del', destination, 'dev', this.name]);
300
- }
301
- else {
302
- throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
303
- }
277
+ await this.platformBackend.removeRoute(this.name, destination);
304
278
  }
305
279
  catch (err) {
306
280
  const message = err.message;
@@ -311,18 +285,15 @@ export class TunTap {
311
285
  }
312
286
  }
313
287
  /**
314
- * Get interface statistics.
288
+ * Fetch RX/TX counters for this interface from the OS (e.g. `netstat` / `ip -s`).
289
+ *
290
+ * @returns byte and packet counts plus error counters
291
+ * @throws {TunTapError} if not ready or stats cannot be parsed
315
292
  */
316
293
  async getStats() {
317
294
  this.assertReady();
318
295
  try {
319
- if (PLATFORM === 'darwin') {
320
- return await this.getStatsDarwin();
321
- }
322
- if (PLATFORM === 'linux') {
323
- return await this.getStatsLinux();
324
- }
325
- throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
296
+ return await this.platformBackend.getStats(this.name);
326
297
  }
327
298
  catch (err) {
328
299
  if (err instanceof TunTapError) {
@@ -331,47 +302,15 @@ export class TunTap {
331
302
  throw new TunTapError(`Failed to get interface statistics: ${err.message}`);
332
303
  }
333
304
  }
334
- async getStatsDarwin() {
335
- const { stdout } = await execFileAsync('netstat', ['-I', this.name, '-b']);
336
- const lines = stdout.trim().split('\n');
337
- if (lines.length < 2) {
338
- throw new TunTapError('Unexpected netstat output');
339
- }
340
- const stats = lines[1].split(/\s+/);
341
- return {
342
- rxPackets: parseInt(stats[4], 10) || 0,
343
- rxErrors: parseInt(stats[5], 10) || 0,
344
- rxBytes: parseInt(stats[6], 10) || 0,
345
- txPackets: parseInt(stats[7], 10) || 0,
346
- txErrors: parseInt(stats[8], 10) || 0,
347
- txBytes: parseInt(stats[9], 10) || 0,
348
- };
349
- }
350
- async getStatsLinux() {
351
- const { stdout } = await execFileAsync('ip', ['-s', 'link', 'show', this.name]);
352
- const lines = stdout.trim().split('\n');
353
- const rxIndex = lines.findIndex((line) => line.includes('RX:'));
354
- const txIndex = lines.findIndex((line) => line.includes('TX:'));
355
- if (rxIndex === -1 || txIndex === -1) {
356
- throw new TunTapError('Could not parse interface statistics');
357
- }
358
- const rxLine = lines[rxIndex + 1]?.trim();
359
- const txLine = lines[txIndex + 1]?.trim();
360
- if (!rxLine || !txLine) {
361
- throw new TunTapError('Could not parse interface statistics: missing data lines');
305
+ /**
306
+ * Throws if the device is not in a usable state (not open or already closed).
307
+ */
308
+ assertReady() {
309
+ if (!this._isOpen) {
310
+ throw new TunTapError('Device not open');
362
311
  }
363
- const rxStats = rxLine.split(/\s+/);
364
- const txStats = txLine.split(/\s+/);
365
- if (rxStats.length < 3 || txStats.length < 3) {
366
- throw new TunTapError('Could not parse interface statistics: unexpected format');
312
+ if (this._isClosed) {
313
+ throw new TunTapError('Device has been closed');
367
314
  }
368
- return {
369
- rxBytes: parseInt(rxStats[0], 10) || 0,
370
- rxPackets: parseInt(rxStats[1], 10) || 0,
371
- rxErrors: parseInt(rxStats[2], 10) || 0,
372
- txBytes: parseInt(txStats[0], 10) || 0,
373
- txPackets: parseInt(txStats[1], 10) || 0,
374
- txErrors: parseInt(txStats[2], 10) || 0,
375
- };
376
315
  }
377
316
  }
@@ -0,0 +1,22 @@
1
+ /** Base error for TUN/TAP and tunnel configuration failures. */
2
+ export declare class TunTapError extends Error {
3
+ code?: string | undefined;
4
+ name: string;
5
+ /**
6
+ * @param message — human-readable description
7
+ * @param code — optional machine-readable code (e.g. `EPERM`)
8
+ */
9
+ constructor(message: string, code?: string | undefined);
10
+ }
11
+ /** Raised when the process lacks privileges required for a networking operation (e.g. not running as root). */
12
+ export declare class TunTapPermissionError extends TunTapError {
13
+ name: string;
14
+ /** @param message — description including hint to run with appropriate privileges */
15
+ constructor(message: string);
16
+ }
17
+ /** Raised when the TUN device cannot be opened or is unavailable. */
18
+ export declare class TunTapDeviceError extends TunTapError {
19
+ name: string;
20
+ /** @param message — description from the native layer or OS */
21
+ constructor(message: string);
22
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,29 @@
1
+ /** Base error for TUN/TAP and tunnel configuration failures. */
2
+ export class TunTapError extends Error {
3
+ code;
4
+ name = 'TunTapError';
5
+ /**
6
+ * @param message — human-readable description
7
+ * @param code — optional machine-readable code (e.g. `EPERM`)
8
+ */
9
+ constructor(message, code) {
10
+ super(message);
11
+ this.code = code;
12
+ }
13
+ }
14
+ /** Raised when the process lacks privileges required for a networking operation (e.g. not running as root). */
15
+ export class TunTapPermissionError extends TunTapError {
16
+ name = 'TunTapPermissionError';
17
+ /** @param message — description including hint to run with appropriate privileges */
18
+ constructor(message) {
19
+ super(message, 'EPERM');
20
+ }
21
+ }
22
+ /** Raised when the TUN device cannot be opened or is unavailable. */
23
+ export class TunTapDeviceError extends TunTapError {
24
+ name = 'TunTapDeviceError';
25
+ /** @param message — description from the native layer or OS */
26
+ constructor(message) {
27
+ super(message, 'ENODEV');
28
+ }
29
+ }
package/lib/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
+ export { TunTapDeviceError, TunTapError, TunTapPermissionError, } from './errors.js';
1
2
  export { TunTap, type PacketCallback } from './TunTap.js';
2
3
  export * from './tunnel.js';
package/lib/index.js CHANGED
@@ -1,2 +1,3 @@
1
+ export { TunTapDeviceError, TunTapError, TunTapPermissionError, } from './errors.js';
1
2
  export { TunTap } from './TunTap.js';
2
3
  export * from './tunnel.js';
@@ -0,0 +1,3 @@
1
+ import type { TunTapPlatform } from './types.js';
2
+ /** @internal Built-in {@link TunTapPlatform} for a Node `process.platform` value. */
3
+ export declare function createTunTapPlatform(platform: NodeJS.Platform): TunTapPlatform;
@@ -0,0 +1,14 @@
1
+ import { DarwinTunTapPlatform } from './darwin.js';
2
+ import { LinuxTunTapPlatform } from './linux.js';
3
+ import { UnsupportedTunTapPlatform } from './unsupported.js';
4
+ /** @internal Built-in {@link TunTapPlatform} for a Node `process.platform` value. */
5
+ export function createTunTapPlatform(platform) {
6
+ switch (platform) {
7
+ case 'darwin':
8
+ return new DarwinTunTapPlatform();
9
+ case 'linux':
10
+ return new LinuxTunTapPlatform();
11
+ default:
12
+ return new UnsupportedTunTapPlatform(platform);
13
+ }
14
+ }
@@ -0,0 +1,12 @@
1
+ import type { TunTapInterfaceStats, TunTapPlatform } from './types.js';
2
+ /** macOS implementation using `ifconfig`, `route`, and `netstat`. */
3
+ export declare class DarwinTunTapPlatform implements TunTapPlatform {
4
+ /** @inheritdoc */
5
+ configure(interfaceName: string, address: string, mtu: number): Promise<void>;
6
+ /** @inheritdoc */
7
+ addRoute(interfaceName: string, destination: string): Promise<void>;
8
+ /** @inheritdoc */
9
+ removeRoute(_interfaceName: string, destination: string): Promise<void>;
10
+ /** @inheritdoc */
11
+ getStats(interfaceName: string): Promise<TunTapInterfaceStats>;
12
+ }
@@ -0,0 +1,53 @@
1
+ import { TunTapError } from '../errors.js';
2
+ import { execFileAsync } from './exec.js';
3
+ import { assertEffectiveRoot } from './require-root.js';
4
+ /** macOS implementation using `ifconfig`, `route`, and `netstat`. */
5
+ export class DarwinTunTapPlatform {
6
+ /** @inheritdoc */
7
+ async configure(interfaceName, address, mtu) {
8
+ assertEffectiveRoot();
9
+ await execFileAsync('ifconfig', [
10
+ interfaceName,
11
+ 'inet6',
12
+ address,
13
+ 'prefixlen',
14
+ '64',
15
+ 'up',
16
+ ]);
17
+ await execFileAsync('ifconfig', [interfaceName, 'mtu', String(mtu)]);
18
+ }
19
+ /** @inheritdoc */
20
+ async addRoute(interfaceName, destination) {
21
+ assertEffectiveRoot();
22
+ await execFileAsync('route', [
23
+ '-n',
24
+ 'add',
25
+ '-inet6',
26
+ destination,
27
+ '-interface',
28
+ interfaceName,
29
+ ]);
30
+ }
31
+ /** @inheritdoc */
32
+ async removeRoute(_interfaceName, destination) {
33
+ assertEffectiveRoot();
34
+ await execFileAsync('route', ['-n', 'delete', '-inet6', destination]);
35
+ }
36
+ /** @inheritdoc */
37
+ async getStats(interfaceName) {
38
+ const { stdout } = await execFileAsync('netstat', ['-I', interfaceName, '-b']);
39
+ const lines = stdout.trim().split('\n');
40
+ if (lines.length < 2) {
41
+ throw new TunTapError('Unexpected netstat output');
42
+ }
43
+ const stats = lines[1].split(/\s+/);
44
+ return {
45
+ rxPackets: parseInt(stats[4], 10) || 0,
46
+ rxErrors: parseInt(stats[5], 10) || 0,
47
+ rxBytes: parseInt(stats[6], 10) || 0,
48
+ txPackets: parseInt(stats[7], 10) || 0,
49
+ txErrors: parseInt(stats[8], 10) || 0,
50
+ txBytes: parseInt(stats[9], 10) || 0,
51
+ };
52
+ }
53
+ }
@@ -0,0 +1,2 @@
1
+ import { execFile } from 'node:child_process';
2
+ export declare const execFileAsync: typeof execFile.__promisify__;
@@ -0,0 +1,3 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ export const execFileAsync = promisify(execFile);
@@ -0,0 +1,12 @@
1
+ import type { TunTapInterfaceStats, TunTapPlatform } from './types.js';
2
+ /** Linux implementation using `ip` from iproute2. */
3
+ export declare class LinuxTunTapPlatform implements TunTapPlatform {
4
+ /** @inheritdoc */
5
+ configure(interfaceName: string, address: string, mtu: number): Promise<void>;
6
+ /** @inheritdoc */
7
+ addRoute(interfaceName: string, destination: string): Promise<void>;
8
+ /** @inheritdoc */
9
+ removeRoute(interfaceName: string, destination: string): Promise<void>;
10
+ /** @inheritdoc */
11
+ getStats(interfaceName: string): Promise<TunTapInterfaceStats>;
12
+ }
@@ -0,0 +1,77 @@
1
+ import { log } from '../logger.js';
2
+ import { TunTapError } from '../errors.js';
3
+ import { execFileAsync } from './exec.js';
4
+ import { assertEffectiveRoot } from './require-root.js';
5
+ /** Linux implementation using `ip` from iproute2. */
6
+ export class LinuxTunTapPlatform {
7
+ /** @inheritdoc */
8
+ async configure(interfaceName, address, mtu) {
9
+ assertEffectiveRoot();
10
+ try {
11
+ await execFileAsync('which', ['ip']);
12
+ }
13
+ catch {
14
+ throw new TunTapError('The "ip" command is not available. Please install iproute2 (e.g., sudo apt install iproute2)');
15
+ }
16
+ try {
17
+ await execFileAsync('ip', ['-6', 'addr', 'add', `${address}/64`, 'dev', interfaceName]);
18
+ }
19
+ catch (err) {
20
+ // `util.promisify(execFile)` rejects with `ExecException` (extends `Error`; adds `stderr`, `code`, …).
21
+ const { message } = err;
22
+ if (!message.includes('File exists')) {
23
+ throw err;
24
+ }
25
+ log.warn(`Address ${address} may already be configured on ${interfaceName}`);
26
+ }
27
+ await execFileAsync('ip', ['link', 'set', 'dev', interfaceName, 'up', 'mtu', String(mtu)]);
28
+ }
29
+ /** @inheritdoc */
30
+ async addRoute(interfaceName, destination) {
31
+ assertEffectiveRoot();
32
+ try {
33
+ await execFileAsync('ip', ['-6', 'route', 'add', destination, 'dev', interfaceName]);
34
+ }
35
+ catch (err) {
36
+ const { message } = err;
37
+ if (message.includes('File exists')) {
38
+ log.info(`Route to ${destination} already exists`);
39
+ return;
40
+ }
41
+ throw err;
42
+ }
43
+ }
44
+ /** @inheritdoc */
45
+ async removeRoute(interfaceName, destination) {
46
+ assertEffectiveRoot();
47
+ await execFileAsync('ip', ['-6', 'route', 'del', destination, 'dev', interfaceName]);
48
+ }
49
+ /** @inheritdoc */
50
+ async getStats(interfaceName) {
51
+ const { stdout } = await execFileAsync('ip', ['-s', 'link', 'show', interfaceName]);
52
+ const lines = stdout.trim().split('\n');
53
+ const rxIndex = lines.findIndex((line) => line.includes('RX:'));
54
+ const txIndex = lines.findIndex((line) => line.includes('TX:'));
55
+ if (rxIndex === -1 || txIndex === -1) {
56
+ throw new TunTapError('Could not parse interface statistics');
57
+ }
58
+ const rxLine = lines[rxIndex + 1]?.trim();
59
+ const txLine = lines[txIndex + 1]?.trim();
60
+ if (!rxLine || !txLine) {
61
+ throw new TunTapError('Could not parse interface statistics: missing data lines');
62
+ }
63
+ const rxStats = rxLine.split(/\s+/);
64
+ const txStats = txLine.split(/\s+/);
65
+ if (rxStats.length < 3 || txStats.length < 3) {
66
+ throw new TunTapError('Could not parse interface statistics: unexpected format');
67
+ }
68
+ return {
69
+ rxBytes: parseInt(rxStats[0], 10) || 0,
70
+ rxPackets: parseInt(rxStats[1], 10) || 0,
71
+ rxErrors: parseInt(rxStats[2], 10) || 0,
72
+ txBytes: parseInt(txStats[0], 10) || 0,
73
+ txPackets: parseInt(txStats[1], 10) || 0,
74
+ txErrors: parseInt(txStats[2], 10) || 0,
75
+ };
76
+ }
77
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Ensures the process has effective superuser privileges (EUID 0).
3
+ * Privileged `ip` / `ifconfig` / `route` calls are executed directly — no `sudo` subprocess.
4
+ */
5
+ export declare function assertEffectiveRoot(): void;
@@ -0,0 +1,11 @@
1
+ import { TunTapPermissionError } from '../errors.js';
2
+ /**
3
+ * Ensures the process has effective superuser privileges (EUID 0).
4
+ * Privileged `ip` / `ifconfig` / `route` calls are executed directly — no `sudo` subprocess.
5
+ */
6
+ export function assertEffectiveRoot() {
7
+ const geteuid = process.geteuid;
8
+ if (typeof geteuid !== 'function' || geteuid.call(process) !== 0) {
9
+ throw new TunTapPermissionError('TUN interface configuration and routing require root privileges (effective UID 0)');
10
+ }
11
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Per-interface traffic counters returned by {@link TunTapPlatform.getStats}.
3
+ */
4
+ export interface TunTapInterfaceStats {
5
+ rxBytes: number;
6
+ txBytes: number;
7
+ rxPackets: number;
8
+ txPackets: number;
9
+ rxErrors: number;
10
+ txErrors: number;
11
+ }
12
+ /**
13
+ * OS-specific commands for configuring the TUN interface (address, MTU, routes, stats).
14
+ * Built-in Darwin/Linux implementations require **effective UID 0** (run the process as root) for
15
+ * {@link TunTapPlatform.configure}, {@link TunTapPlatform.addRoute}, and {@link TunTapPlatform.removeRoute}.
16
+ * Add a new implementation (e.g. Windows) by extending this interface and wiring it in `create-platform.ts`.
17
+ */
18
+ export interface TunTapPlatform {
19
+ /**
20
+ * Assign an IPv6 address (typically /64) and MTU on the interface, and bring it up.
21
+ *
22
+ * @param interfaceName — kernel interface name (e.g. `utun7`)
23
+ * @param address — IPv6 address
24
+ * @param mtu — link MTU in bytes
25
+ */
26
+ configure(interfaceName: string, address: string, mtu: number): Promise<void>;
27
+ /**
28
+ * Add an IPv6 route via this interface.
29
+ *
30
+ * @param interfaceName — kernel interface name
31
+ * @param destination — IPv6 host or CIDR (e.g. `fd00::1/128`)
32
+ */
33
+ addRoute(interfaceName: string, destination: string): Promise<void>;
34
+ /**
35
+ * Remove an IPv6 route for the given destination.
36
+ *
37
+ * @param interfaceName — kernel interface name (ignored on some platforms)
38
+ * @param destination — same form as passed to {@link TunTapPlatform.addRoute}
39
+ */
40
+ removeRoute(interfaceName: string, destination: string): Promise<void>;
41
+ /**
42
+ * Read RX/TX byte and packet counters for the interface from the OS.
43
+ *
44
+ * @param interfaceName — kernel interface name
45
+ * @returns interface statistics
46
+ */
47
+ getStats(interfaceName: string): Promise<TunTapInterfaceStats>;
48
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import type { TunTapInterfaceStats, TunTapPlatform } from './types.js';
2
+ /**
3
+ * Stub backend for unsupported `process.platform` values; every method throws {@link TunTapError}.
4
+ */
5
+ export declare class UnsupportedTunTapPlatform implements TunTapPlatform {
6
+ private readonly platformId;
7
+ /**
8
+ * @param platformId — value from `process.platform` (or test override)
9
+ */
10
+ constructor(platformId: string);
11
+ /** @inheritdoc */
12
+ configure(): Promise<void>;
13
+ /** @inheritdoc */
14
+ addRoute(): Promise<void>;
15
+ /** @inheritdoc */
16
+ removeRoute(): Promise<void>;
17
+ /** @inheritdoc */
18
+ getStats(): Promise<TunTapInterfaceStats>;
19
+ private unsupported;
20
+ }
@@ -0,0 +1,32 @@
1
+ import { TunTapError } from '../errors.js';
2
+ /**
3
+ * Stub backend for unsupported `process.platform` values; every method throws {@link TunTapError}.
4
+ */
5
+ export class UnsupportedTunTapPlatform {
6
+ platformId;
7
+ /**
8
+ * @param platformId — value from `process.platform` (or test override)
9
+ */
10
+ constructor(platformId) {
11
+ this.platformId = platformId;
12
+ }
13
+ /** @inheritdoc */
14
+ async configure() {
15
+ this.unsupported();
16
+ }
17
+ /** @inheritdoc */
18
+ async addRoute() {
19
+ this.unsupported();
20
+ }
21
+ /** @inheritdoc */
22
+ async removeRoute() {
23
+ this.unsupported();
24
+ }
25
+ /** @inheritdoc */
26
+ async getStats() {
27
+ this.unsupported();
28
+ }
29
+ unsupported() {
30
+ throw new TunTapError(`Unsupported platform: ${this.platformId}`);
31
+ }
32
+ }
package/lib/tunnel.d.ts CHANGED
@@ -19,19 +19,44 @@ export interface PacketData {
19
19
  destPort: number;
20
20
  payload: Buffer;
21
21
  }
22
+ /**
23
+ * Event names and listener argument tuples for {@link TunnelManager}
24
+ * (matches Node’s `EventEmitter` event map shape).
25
+ *
26
+ * @example
27
+ * tunnelManager.on('data', (packet) => {
28
+ * // `packet` is PacketData
29
+ * });
30
+ */
31
+ export interface TunnelManagerEvents {
32
+ data: [packet: PacketData];
33
+ }
22
34
  export interface PacketConsumer {
35
+ /**
36
+ * Invoked for each parsed TCP/UDP payload extracted from the tunnel stream.
37
+ *
38
+ * @param packet — decoded addresses, ports, and payload
39
+ */
23
40
  onPacket(packet: PacketData): void;
24
41
  }
25
42
  export interface TunnelConnection {
26
43
  Address: string;
27
44
  RsdPort?: number;
28
45
  tunnelManager: TunnelManager;
46
+ /** Tear down the tunnel, close the TUN device, and end the socket when appropriate. */
29
47
  closer: () => Promise<void>;
48
+ /** @param consumer — receives packets for the lifetime of the registration */
30
49
  addPacketConsumer(consumer: PacketConsumer): void;
50
+ /** @param consumer — must be the same reference passed to {@link TunnelConnection.addPacketConsumer} */
31
51
  removePacketConsumer(consumer: PacketConsumer): void;
52
+ /** @returns async iterator of packets until the tunnel is stopped */
32
53
  getPacketStream(): AsyncIterable<PacketData>;
33
54
  }
34
- export declare class TunnelManager extends EventEmitter {
55
+ /**
56
+ * Bridges a CoreDevice tunnel `Socket` and a {@link TunTap} interface: IPv6 framing, TUN I/O, and packet fan-out.
57
+ * Emits {@link TunnelManagerEvents} (currently `data` with {@link PacketData}) for TCP/UDP packets, same as registered consumers.
58
+ */
59
+ export declare class TunnelManager extends EventEmitter<TunnelManagerEvents> {
35
60
  private tun;
36
61
  private cancelled;
37
62
  private readInterval;
@@ -40,21 +65,65 @@ export declare class TunnelManager extends EventEmitter {
40
65
  private packetQueue;
41
66
  private deviceConn;
42
67
  private cleanupPromise;
68
+ /** Creates a manager with no TUN device until {@link TunnelManager.setupInterface} succeeds. */
43
69
  constructor();
70
+ /**
71
+ * Register a listener for parsed tunnel packets (in addition to the `data` event).
72
+ *
73
+ * @param consumer — object with {@link PacketConsumer.onPacket}
74
+ */
44
75
  addPacketConsumer(consumer: PacketConsumer): void;
76
+ /**
77
+ * Unregister a consumer previously added with {@link TunnelManager.addPacketConsumer}.
78
+ *
79
+ * @param consumer — same reference as passed to `addPacketConsumer`
80
+ */
45
81
  removePacketConsumer(consumer: PacketConsumer): void;
82
+ /**
83
+ * Async iterator over tunnel packets until {@link TunnelManager.stop} sets `cancelled`.
84
+ *
85
+ * @yields {@link PacketData} for each TCP/UDP packet
86
+ */
46
87
  getPacketStream(): AsyncIterable<PacketData>;
88
+ /**
89
+ * Open a {@link TunTap}, assign the client IPv6 address/MTU, and add a /128 route to the server.
90
+ *
91
+ * @param tunnelInfo — handshake result (client address, MTU, server address)
92
+ * @returns interface name, MTU, and the live {@link TunTap} instance
93
+ */
47
94
  setupInterface(tunnelInfo: TunnelInfo): Promise<{
48
95
  name: string;
49
96
  mtu: number;
50
97
  interface: TunTap;
51
98
  }>;
99
+ /**
100
+ * Begin bidirectional forwarding: device socket ↔ TUN (requires {@link TunnelManager.setupInterface} first).
101
+ *
102
+ * @param deviceConn — connected tunnel socket after the CDTunnel handshake
103
+ */
52
104
  startForwarding(deviceConn: Socket): void;
105
+ /**
106
+ * Idempotent shutdown: stop polling, destroy the socket, clear consumers, close the TUN device.
107
+ *
108
+ * @returns the same promise if already stopping/stopped
109
+ */
110
+ stop(): Promise<void>;
53
111
  private processBuffer;
54
112
  private startTunReadLoop;
55
- stop(): Promise<void>;
56
113
  private _performStop;
57
114
  }
115
+ /**
116
+ * Perform the CDTunnel JSON handshake (8-byte magic + length-prefixed JSON) over `socket`.
117
+ *
118
+ * @param socket — connected stream to the tunnel service
119
+ * @returns parsed tunnel parameters from the device response
120
+ */
58
121
  export declare function exchangeCoreTunnelParameters(socket: Socket): Promise<TunnelInfo>;
122
+ /**
123
+ * End-to-end setup: handshake, TUN configuration, route, and forwarding on `secureServiceSocket`.
124
+ *
125
+ * @param secureServiceSocket — tunnel socket (e.g. from lockdown secure service)
126
+ * @returns connection handle with {@link TunnelConnection.closer} and packet APIs
127
+ */
59
128
  export declare function connectToTunnelLockdown(secureServiceSocket: Socket): Promise<TunnelConnection>;
60
129
  export {};
package/lib/tunnel.js CHANGED
@@ -2,6 +2,10 @@ import { log } from './logger.js';
2
2
  import { TunTap } from './TunTap.js';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import { Buffer } from 'node:buffer';
5
+ /**
6
+ * Bridges a CoreDevice tunnel `Socket` and a {@link TunTap} interface: IPv6 framing, TUN I/O, and packet fan-out.
7
+ * Emits {@link TunnelManagerEvents} (currently `data` with {@link PacketData}) for TCP/UDP packets, same as registered consumers.
8
+ */
5
9
  export class TunnelManager extends EventEmitter {
6
10
  tun;
7
11
  cancelled;
@@ -11,6 +15,7 @@ export class TunnelManager extends EventEmitter {
11
15
  packetQueue;
12
16
  deviceConn;
13
17
  cleanupPromise;
18
+ /** Creates a manager with no TUN device until {@link TunnelManager.setupInterface} succeeds. */
14
19
  constructor() {
15
20
  super();
16
21
  this.tun = null;
@@ -22,12 +27,27 @@ export class TunnelManager extends EventEmitter {
22
27
  this.deviceConn = null;
23
28
  this.cleanupPromise = null;
24
29
  }
30
+ /**
31
+ * Register a listener for parsed tunnel packets (in addition to the `data` event).
32
+ *
33
+ * @param consumer — object with {@link PacketConsumer.onPacket}
34
+ */
25
35
  addPacketConsumer(consumer) {
26
36
  this.packetConsumers.add(consumer);
27
37
  }
38
+ /**
39
+ * Unregister a consumer previously added with {@link TunnelManager.addPacketConsumer}.
40
+ *
41
+ * @param consumer — same reference as passed to `addPacketConsumer`
42
+ */
28
43
  removePacketConsumer(consumer) {
29
44
  this.packetConsumers.delete(consumer);
30
45
  }
46
+ /**
47
+ * Async iterator over tunnel packets until {@link TunnelManager.stop} sets `cancelled`.
48
+ *
49
+ * @yields {@link PacketData} for each TCP/UDP packet
50
+ */
31
51
  async *getPacketStream() {
32
52
  const queue = [];
33
53
  let resolver = null;
@@ -64,6 +84,12 @@ export class TunnelManager extends EventEmitter {
64
84
  this.removePacketConsumer(consumer);
65
85
  }
66
86
  }
87
+ /**
88
+ * Open a {@link TunTap}, assign the client IPv6 address/MTU, and add a /128 route to the server.
89
+ *
90
+ * @param tunnelInfo — handshake result (client address, MTU, server address)
91
+ * @returns interface name, MTU, and the live {@link TunTap} instance
92
+ */
67
93
  async setupInterface(tunnelInfo) {
68
94
  log.debug(`Setting up tunnel with parameters:`, tunnelInfo);
69
95
  try {
@@ -98,6 +124,11 @@ export class TunnelManager extends EventEmitter {
98
124
  throw err;
99
125
  }
100
126
  }
127
+ /**
128
+ * Begin bidirectional forwarding: device socket ↔ TUN (requires {@link TunnelManager.setupInterface} first).
129
+ *
130
+ * @param deviceConn — connected tunnel socket after the CDTunnel handshake
131
+ */
101
132
  startForwarding(deviceConn) {
102
133
  if (!this.tun) {
103
134
  log.error('TUN device is not set up');
@@ -138,6 +169,19 @@ export class TunnelManager extends EventEmitter {
138
169
  log.error('Device connection error: ', err);
139
170
  });
140
171
  }
172
+ /**
173
+ * Idempotent shutdown: stop polling, destroy the socket, clear consumers, close the TUN device.
174
+ *
175
+ * @returns the same promise if already stopping/stopped
176
+ */
177
+ async stop() {
178
+ // Prevent multiple concurrent stops
179
+ if (this.cleanupPromise) {
180
+ return this.cleanupPromise;
181
+ }
182
+ this.cleanupPromise = this._performStop();
183
+ return this.cleanupPromise;
184
+ }
141
185
  processBuffer() {
142
186
  let offset = 0;
143
187
  // Process as many complete packets as available
@@ -283,14 +327,6 @@ export class TunnelManager extends EventEmitter {
283
327
  }
284
328
  }, 5); // Poll every 5ms
285
329
  }
286
- async stop() {
287
- // Prevent multiple concurrent stops
288
- if (this.cleanupPromise) {
289
- return this.cleanupPromise;
290
- }
291
- this.cleanupPromise = this._performStop();
292
- return this.cleanupPromise;
293
- }
294
330
  async _performStop() {
295
331
  const tunName = this.tun ? this.tun.name : 'unknown';
296
332
  log.debug(`Stopping tunnel manager for ${tunName}`);
@@ -335,6 +371,12 @@ function formatIPv6Address(buffer) {
335
371
  }
336
372
  return parts.join(':');
337
373
  }
374
+ /**
375
+ * Perform the CDTunnel JSON handshake (8-byte magic + length-prefixed JSON) over `socket`.
376
+ *
377
+ * @param socket — connected stream to the tunnel service
378
+ * @returns parsed tunnel parameters from the device response
379
+ */
338
380
  export async function exchangeCoreTunnelParameters(socket) {
339
381
  return new Promise((resolve, reject) => {
340
382
  const request = {
@@ -412,6 +454,12 @@ export async function exchangeCoreTunnelParameters(socket) {
412
454
  socket.on('end', handleEnd);
413
455
  });
414
456
  }
457
+ /**
458
+ * End-to-end setup: handshake, TUN configuration, route, and forwarding on `secureServiceSocket`.
459
+ *
460
+ * @param secureServiceSocket — tunnel socket (e.g. from lockdown secure service)
461
+ * @returns connection handle with {@link TunnelConnection.closer} and packet APIs
462
+ */
415
463
  export async function connectToTunnelLockdown(secureServiceSocket) {
416
464
  const tunnelManager = new TunnelManager();
417
465
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-tuntap",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Native TUN/TAP interface module for Node.js",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@appium/support": "^7.0.0-rc.1",
45
45
  "node-addon-api": "^8.5.0",
46
- "typescript": "^5.8.3"
46
+ "typescript": "^6.0.2"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@appium/eslint-config-appium-ts": "^3.0.0",