appium-ios-tuntap 0.1.7 → 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 +6 -0
- package/build/Release/tuntap.node +0 -0
- package/build/config.gypi +1 -1
- package/lib/TunTap.d.ts +88 -30
- package/lib/TunTap.js +91 -152
- package/lib/errors.d.ts +22 -0
- package/lib/errors.js +29 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/platform/create-platform.d.ts +3 -0
- package/lib/platform/create-platform.js +14 -0
- package/lib/platform/darwin.d.ts +12 -0
- package/lib/platform/darwin.js +53 -0
- package/lib/platform/exec.d.ts +2 -0
- package/lib/platform/exec.js +3 -0
- package/lib/platform/linux.d.ts +12 -0
- package/lib/platform/linux.js +77 -0
- package/lib/platform/require-root.d.ts +5 -0
- package/lib/platform/require-root.js +11 -0
- package/lib/platform/types.d.ts +48 -0
- package/lib/platform/types.js +1 -0
- package/lib/platform/unsupported.d.ts +20 -0
- package/lib/platform/unsupported.js +32 -0
- package/lib/tunnel.d.ts +71 -2
- package/lib/tunnel.js +56 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.1.7](https://github.com/appium/appium-ios-tuntap/compare/v0.1.6...v0.1.7) (2026-04-11)
|
|
2
8
|
|
|
3
9
|
### Miscellaneous Chores
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
33
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
177
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
}
|
package/lib/errors.d.ts
ADDED
|
@@ -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
package/lib/index.js
CHANGED
|
@@ -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,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,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
|
-
|
|
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 {
|