appium-ios-tuntap 0.1.4 → 0.1.6
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 +12 -0
- package/build/Release/obj.target/tuntap/src/tuntap.o +0 -0
- package/build/Release/tuntap.node +0 -0
- package/build/config.gypi +1 -1
- package/lib/TunTap.d.ts +20 -4
- package/lib/TunTap.js +209 -193
- package/lib/index.d.ts +1 -1
- package/lib/tunnel.js +0 -44
- package/package.json +1 -1
- package/src/tuntap.cc +123 -160
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.1.6](https://github.com/appium/appium-ios-tuntap/compare/v0.1.5...v0.1.6) (2026-04-11)
|
|
2
|
+
|
|
3
|
+
### Code Refactoring
|
|
4
|
+
|
|
5
|
+
* refactor TUN/TAP native layer and harden TunTap TypeScript interface ([3fa00fe](https://github.com/appium/appium-ios-tuntap/commit/3fa00fe57a7cc44e6490d8e97d7defe2f782c0dc))
|
|
6
|
+
|
|
7
|
+
## [0.1.5](https://github.com/appium/appium-ios-tuntap/compare/v0.1.4...v0.1.5) (2026-04-09)
|
|
8
|
+
|
|
9
|
+
### Code Refactoring
|
|
10
|
+
|
|
11
|
+
* remove global signal handlers from library code ([6e267df](https://github.com/appium/appium-ios-tuntap/commit/6e267dffe1785acfcd57d431090e4cd4995bfa39))
|
|
12
|
+
|
|
1
13
|
## [0.1.4](https://github.com/appium/appium-ios-tuntap/compare/v0.1.3...v0.1.4) (2026-04-08)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
Binary file
|
|
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/8df09367f00966ee4ef2275480f452a9/.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,3 +1,4 @@
|
|
|
1
|
+
export type PacketCallback = (data: Buffer) => void;
|
|
1
2
|
export declare class TunTapError extends Error {
|
|
2
3
|
code?: string | undefined;
|
|
3
4
|
constructor(message: string, code?: string | undefined);
|
|
@@ -13,21 +14,34 @@ export declare class TunTapDeviceError extends TunTapError {
|
|
|
13
14
|
*/
|
|
14
15
|
export declare class TunTap {
|
|
15
16
|
private device;
|
|
16
|
-
private
|
|
17
|
-
private
|
|
18
|
-
private
|
|
17
|
+
private _isOpen;
|
|
18
|
+
private _isClosed;
|
|
19
|
+
private removeExitListener;
|
|
19
20
|
constructor(name?: string);
|
|
21
|
+
get isOpen(): boolean;
|
|
22
|
+
get isClosed(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Throws if the device is not in a usable state (not open or already closed).
|
|
25
|
+
*/
|
|
26
|
+
private assertReady;
|
|
20
27
|
open(): boolean;
|
|
21
28
|
close(): boolean;
|
|
22
29
|
read(maxSize?: number): Buffer;
|
|
23
30
|
write(data: Buffer): number;
|
|
31
|
+
/**
|
|
32
|
+
* Start event-driven reading from the TUN device.
|
|
33
|
+
* The callback is invoked with each packet read from the device.
|
|
34
|
+
*/
|
|
35
|
+
startPolling(callback: PacketCallback, bufferSize?: number): void;
|
|
24
36
|
get name(): string;
|
|
25
37
|
get fd(): number;
|
|
26
38
|
configure(address: string, mtu?: number): Promise<void>;
|
|
39
|
+
private configureLinux;
|
|
27
40
|
addRoute(destination: string): Promise<void>;
|
|
41
|
+
private addRouteLinux;
|
|
28
42
|
removeRoute(destination: string): Promise<void>;
|
|
29
43
|
/**
|
|
30
|
-
* Get interface statistics
|
|
44
|
+
* Get interface statistics.
|
|
31
45
|
*/
|
|
32
46
|
getStats(): Promise<{
|
|
33
47
|
rxBytes: number;
|
|
@@ -37,4 +51,6 @@ export declare class TunTap {
|
|
|
37
51
|
rxErrors: number;
|
|
38
52
|
txErrors: number;
|
|
39
53
|
}>;
|
|
54
|
+
private getStatsDarwin;
|
|
55
|
+
private getStatsLinux;
|
|
40
56
|
}
|
package/lib/TunTap.js
CHANGED
|
@@ -1,35 +1,16 @@
|
|
|
1
|
-
import { createRequire } from 'node:module';
|
|
2
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
3
|
import { isIPv6 } from 'node:net';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { log } from './logger.js';
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
|
-
const nativeTuntap = require('../build/Release/tuntap.node');
|
|
8
7
|
const execFileAsync = promisify(execFile);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
const parts = destination.split('/');
|
|
18
|
-
if (parts.length > 2) {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
const [addr, prefixLen] = parts;
|
|
22
|
-
if (!isIPv6(addr)) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
if (prefixLen !== undefined) {
|
|
26
|
-
const len = Number(prefixLen);
|
|
27
|
-
if (!Number.isInteger(len) || len < 0 || len > 128) {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
8
|
+
const PLATFORM = process.platform;
|
|
9
|
+
const DEFAULT_READ_BUFFER_SIZE = 4096;
|
|
10
|
+
const MAX_BUFFER_SIZE = 0xFFFF; // 65535
|
|
11
|
+
const DEFAULT_MTU = 1500;
|
|
12
|
+
const MIN_MTU = 1280;
|
|
13
|
+
const nativeTuntap = require('../build/Release/tuntap.node');
|
|
33
14
|
// Custom error types
|
|
34
15
|
export class TunTapError extends Error {
|
|
35
16
|
code;
|
|
@@ -51,73 +32,105 @@ export class TunTapDeviceError extends TunTapError {
|
|
|
51
32
|
this.name = 'TunTapDeviceError';
|
|
52
33
|
}
|
|
53
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Validates an IPv6 route destination (address with optional CIDR prefix).
|
|
37
|
+
*/
|
|
38
|
+
function isValidIPv6Route(destination) {
|
|
39
|
+
const parts = destination.split('/');
|
|
40
|
+
if (parts.length > 2) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (parts.length === 2) {
|
|
44
|
+
const prefix = parseInt(parts[1], 10);
|
|
45
|
+
if (isNaN(prefix) || prefix < 0 || prefix > 128 || parts[1] !== String(prefix)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return isIPv6(parts[0]);
|
|
50
|
+
}
|
|
54
51
|
/**
|
|
55
52
|
* TUN/TAP device for IP tunneling
|
|
56
53
|
*/
|
|
57
54
|
export class TunTap {
|
|
58
55
|
device;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
_isOpen;
|
|
57
|
+
_isClosed;
|
|
58
|
+
removeExitListener = null;
|
|
62
59
|
constructor(name = '') {
|
|
63
60
|
this.device = new nativeTuntap.TunDevice(name);
|
|
64
|
-
this.
|
|
65
|
-
this.
|
|
66
|
-
// Register cleanup on process exit
|
|
61
|
+
this._isOpen = false;
|
|
62
|
+
this._isClosed = false;
|
|
63
|
+
// Register cleanup on process exit only.
|
|
64
|
+
// Signal handling is the caller's responsibility — libraries should not
|
|
65
|
+
// install global signal handlers. The kernel cleans up the TUN fd on exit.
|
|
67
66
|
const cleanup = () => {
|
|
68
|
-
if (this.
|
|
67
|
+
if (this._isOpen && !this._isClosed) {
|
|
69
68
|
try {
|
|
70
69
|
this.close();
|
|
71
70
|
}
|
|
72
71
|
catch (err) {
|
|
73
|
-
log.error('Error closing TUN device during cleanup:', err);
|
|
72
|
+
log.error('Error closing TUN device during cleanup:', err.message);
|
|
74
73
|
}
|
|
75
74
|
}
|
|
76
75
|
};
|
|
77
76
|
process.once('exit', cleanup);
|
|
78
|
-
|
|
79
|
-
process.once('SIGTERM', cleanup);
|
|
80
|
-
this.cleanupHandlers.push(() => {
|
|
77
|
+
this.removeExitListener = () => {
|
|
81
78
|
process.removeListener('exit', cleanup);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
get isOpen() {
|
|
82
|
+
return this._isOpen;
|
|
83
|
+
}
|
|
84
|
+
get isClosed() {
|
|
85
|
+
return this._isClosed;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Throws if the device is not in a usable state (not open or already closed).
|
|
89
|
+
*/
|
|
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
|
+
}
|
|
85
97
|
}
|
|
86
98
|
open() {
|
|
87
|
-
if (this.
|
|
99
|
+
if (this._isClosed) {
|
|
88
100
|
throw new TunTapError('Device has been closed and cannot be reopened');
|
|
89
101
|
}
|
|
90
|
-
if (!this.
|
|
102
|
+
if (!this._isOpen) {
|
|
91
103
|
try {
|
|
92
|
-
this.
|
|
93
|
-
if (!this.
|
|
104
|
+
this._isOpen = this.device.open();
|
|
105
|
+
if (!this._isOpen) {
|
|
94
106
|
throw new TunTapDeviceError('Failed to open TUN device');
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
109
|
catch (err) {
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
throw new TunTapPermissionError(
|
|
110
|
+
const message = err.message ?? '';
|
|
111
|
+
if (message.includes('Permission denied') || message.includes('sudo')) {
|
|
112
|
+
throw new TunTapPermissionError(message);
|
|
101
113
|
}
|
|
102
|
-
|
|
103
|
-
throw new TunTapDeviceError(
|
|
114
|
+
if (message.includes('not available') || message.includes('does not exist')) {
|
|
115
|
+
throw new TunTapDeviceError(message);
|
|
104
116
|
}
|
|
105
117
|
throw err;
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
|
-
return this.
|
|
120
|
+
return this._isOpen;
|
|
109
121
|
}
|
|
110
122
|
close() {
|
|
111
|
-
if (
|
|
123
|
+
if (this.removeExitListener) {
|
|
124
|
+
this.removeExitListener();
|
|
125
|
+
this.removeExitListener = null;
|
|
126
|
+
}
|
|
127
|
+
if (!this._isClosed) {
|
|
112
128
|
try {
|
|
113
|
-
if (this.
|
|
129
|
+
if (this._isOpen) {
|
|
114
130
|
this.device.close();
|
|
115
|
-
this.
|
|
131
|
+
this._isOpen = false;
|
|
116
132
|
}
|
|
117
|
-
this.
|
|
118
|
-
// Run cleanup handlers
|
|
119
|
-
this.cleanupHandlers.forEach((handler) => handler());
|
|
120
|
-
this.cleanupHandlers = [];
|
|
133
|
+
this._isClosed = true;
|
|
121
134
|
}
|
|
122
135
|
catch (err) {
|
|
123
136
|
throw new TunTapError(`Failed to close device: ${err.message}`);
|
|
@@ -125,15 +138,10 @@ export class TunTap {
|
|
|
125
138
|
}
|
|
126
139
|
return true;
|
|
127
140
|
}
|
|
128
|
-
read(maxSize =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (this.isClosed) {
|
|
133
|
-
throw new TunTapError('Device has been closed');
|
|
134
|
-
}
|
|
135
|
-
if (maxSize <= 0 || maxSize > 65536) {
|
|
136
|
-
throw new RangeError('Read size must be between 1 and 65536 bytes');
|
|
141
|
+
read(maxSize = DEFAULT_READ_BUFFER_SIZE) {
|
|
142
|
+
this.assertReady();
|
|
143
|
+
if (maxSize <= 0 || maxSize > MAX_BUFFER_SIZE) {
|
|
144
|
+
throw new RangeError(`Read size must be between 1 and ${MAX_BUFFER_SIZE} bytes`);
|
|
137
145
|
}
|
|
138
146
|
try {
|
|
139
147
|
return this.device.read(maxSize);
|
|
@@ -143,20 +151,15 @@ export class TunTap {
|
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
write(data) {
|
|
146
|
-
|
|
147
|
-
throw new TunTapError('Device not open');
|
|
148
|
-
}
|
|
149
|
-
if (this.isClosed) {
|
|
150
|
-
throw new TunTapError('Device has been closed');
|
|
151
|
-
}
|
|
154
|
+
this.assertReady();
|
|
152
155
|
if (!Buffer.isBuffer(data)) {
|
|
153
156
|
throw new TypeError('Data must be a Buffer');
|
|
154
157
|
}
|
|
155
158
|
if (data.length === 0) {
|
|
156
159
|
return 0;
|
|
157
160
|
}
|
|
158
|
-
if (data.length >
|
|
159
|
-
throw new RangeError(
|
|
161
|
+
if (data.length > MAX_BUFFER_SIZE) {
|
|
162
|
+
throw new RangeError(`Write data too large (max ${MAX_BUFFER_SIZE} bytes)`);
|
|
160
163
|
}
|
|
161
164
|
try {
|
|
162
165
|
const result = this.device.write(data);
|
|
@@ -169,57 +172,44 @@ export class TunTap {
|
|
|
169
172
|
throw new TunTapError(`Write failed: ${err.message}`);
|
|
170
173
|
}
|
|
171
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Start event-driven reading from the TUN device.
|
|
177
|
+
* The callback is invoked with each packet read from the device.
|
|
178
|
+
*/
|
|
179
|
+
startPolling(callback, bufferSize = MAX_BUFFER_SIZE) {
|
|
180
|
+
this.assertReady();
|
|
181
|
+
if (typeof callback !== 'function') {
|
|
182
|
+
throw new TypeError('Callback must be a function');
|
|
183
|
+
}
|
|
184
|
+
if (bufferSize <= 0 || bufferSize > MAX_BUFFER_SIZE) {
|
|
185
|
+
throw new RangeError(`Buffer size must be between 1 and ${MAX_BUFFER_SIZE} bytes`);
|
|
186
|
+
}
|
|
187
|
+
this.device.startPolling(callback, bufferSize);
|
|
188
|
+
}
|
|
172
189
|
get name() {
|
|
173
190
|
return this.device.getName();
|
|
174
191
|
}
|
|
175
192
|
get fd() {
|
|
176
193
|
return this.device.getFd();
|
|
177
194
|
}
|
|
178
|
-
async configure(address, mtu =
|
|
179
|
-
|
|
180
|
-
throw new TunTapError('Device not open');
|
|
181
|
-
}
|
|
182
|
-
if (this.isClosed) {
|
|
183
|
-
throw new TunTapError('Device has been closed');
|
|
184
|
-
}
|
|
195
|
+
async configure(address, mtu = DEFAULT_MTU) {
|
|
196
|
+
this.assertReady();
|
|
185
197
|
if (!isIPv6(address)) {
|
|
186
198
|
throw new TypeError('Invalid IPv6 address format');
|
|
187
199
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
throw new RangeError('MTU must be between 1280 and 65535');
|
|
200
|
+
if (mtu < MIN_MTU || mtu > MAX_BUFFER_SIZE) {
|
|
201
|
+
throw new RangeError(`MTU must be between ${MIN_MTU} and ${MAX_BUFFER_SIZE}`);
|
|
191
202
|
}
|
|
192
|
-
const platform = process.platform;
|
|
193
203
|
try {
|
|
194
|
-
if (
|
|
204
|
+
if (PLATFORM === 'darwin') {
|
|
195
205
|
await execFileAsync('sudo', ['ifconfig', this.name, 'inet6', address, 'prefixlen', '64', 'up']);
|
|
196
206
|
await execFileAsync('sudo', ['ifconfig', this.name, 'mtu', String(mtu)]);
|
|
197
207
|
}
|
|
198
|
-
else if (
|
|
199
|
-
|
|
200
|
-
await execFileAsync('which', ['ip']);
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
throw new TunTapError('The "ip" command is not available. Please install the iproute2 package (e.g., sudo apt install iproute2)');
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
await execFileAsync('sudo', ['ip', '-6', 'addr', 'add', `${address}/64`, 'dev', this.name]);
|
|
207
|
-
await execFileAsync('sudo', ['ip', 'link', 'set', 'dev', this.name, 'up', 'mtu', String(mtu)]);
|
|
208
|
-
}
|
|
209
|
-
catch (err) {
|
|
210
|
-
if (err.message.includes('Permission denied')) {
|
|
211
|
-
throw new TunTapPermissionError(`Permission denied when configuring network interface. Make sure you have sudo privileges or run the application with sudo.`);
|
|
212
|
-
}
|
|
213
|
-
else if (err.message.includes('File exists')) {
|
|
214
|
-
log.warn(`Address ${address} may already be configured on ${this.name}`);
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
throw err;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
208
|
+
else if (PLATFORM === 'linux') {
|
|
209
|
+
await this.configureLinux(address, mtu);
|
|
220
210
|
}
|
|
221
211
|
else {
|
|
222
|
-
throw new TunTapError(`Unsupported platform: ${
|
|
212
|
+
throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
|
|
223
213
|
}
|
|
224
214
|
}
|
|
225
215
|
catch (err) {
|
|
@@ -229,133 +219,159 @@ export class TunTap {
|
|
|
229
219
|
throw new TunTapError(`Failed to configure TUN interface: ${err.message}`);
|
|
230
220
|
}
|
|
231
221
|
}
|
|
232
|
-
async
|
|
233
|
-
|
|
234
|
-
|
|
222
|
+
async configureLinux(address, mtu) {
|
|
223
|
+
try {
|
|
224
|
+
await execFileAsync('which', ['ip']);
|
|
235
225
|
}
|
|
236
|
-
|
|
237
|
-
throw new TunTapError('
|
|
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
|
+
}
|
|
244
|
+
async addRoute(destination) {
|
|
245
|
+
this.assertReady();
|
|
246
|
+
if (!destination || typeof destination !== 'string') {
|
|
247
|
+
throw new TypeError('Destination must be a non-empty string');
|
|
238
248
|
}
|
|
239
249
|
if (!isValidIPv6Route(destination)) {
|
|
240
|
-
throw new TypeError('Destination must be a valid IPv6 address or
|
|
250
|
+
throw new TypeError('Destination must be a valid IPv6 address or CIDR (e.g., fd00::1/128)');
|
|
241
251
|
}
|
|
242
|
-
const platform = process.platform;
|
|
243
252
|
try {
|
|
244
|
-
if (
|
|
253
|
+
if (PLATFORM === 'darwin') {
|
|
245
254
|
await execFileAsync('sudo', ['route', '-n', 'add', '-inet6', destination, '-interface', this.name]);
|
|
246
255
|
}
|
|
247
|
-
else if (
|
|
248
|
-
|
|
249
|
-
await execFileAsync('sudo', ['ip', '-6', 'route', 'add', destination, 'dev', this.name]);
|
|
250
|
-
}
|
|
251
|
-
catch (err) {
|
|
252
|
-
if (err.message.includes('Permission denied')) {
|
|
253
|
-
throw new TunTapPermissionError(`Permission denied when adding route. Make sure you have sudo privileges or run the application with sudo.`);
|
|
254
|
-
}
|
|
255
|
-
else if (err.message.includes('File exists')) {
|
|
256
|
-
log.info(`Route to ${destination} already exists`);
|
|
257
|
-
}
|
|
258
|
-
else {
|
|
259
|
-
throw err;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
256
|
+
else if (PLATFORM === 'linux') {
|
|
257
|
+
await this.addRouteLinux(destination);
|
|
262
258
|
}
|
|
263
259
|
else {
|
|
264
|
-
throw new TunTapError(`Unsupported platform: ${
|
|
260
|
+
throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
|
|
265
261
|
}
|
|
266
262
|
}
|
|
267
263
|
catch (err) {
|
|
268
264
|
if (err instanceof TunTapError) {
|
|
269
265
|
throw err;
|
|
270
266
|
}
|
|
271
|
-
|
|
272
|
-
|
|
267
|
+
throw new TunTapError(`Failed to add route: ${err.message}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
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.');
|
|
273
278
|
}
|
|
279
|
+
if (message.includes('File exists')) {
|
|
280
|
+
log.info(`Route to ${destination} already exists`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
throw err;
|
|
274
284
|
}
|
|
275
285
|
}
|
|
276
286
|
async removeRoute(destination) {
|
|
277
|
-
|
|
278
|
-
|
|
287
|
+
this.assertReady();
|
|
288
|
+
if (!destination || typeof destination !== 'string') {
|
|
289
|
+
throw new TypeError('Destination must be a non-empty string');
|
|
279
290
|
}
|
|
280
291
|
if (!isValidIPv6Route(destination)) {
|
|
281
|
-
throw new TypeError('Destination must be a valid IPv6 address or
|
|
292
|
+
throw new TypeError('Destination must be a valid IPv6 address or CIDR');
|
|
282
293
|
}
|
|
283
|
-
const platform = process.platform;
|
|
284
294
|
try {
|
|
285
|
-
if (
|
|
295
|
+
if (PLATFORM === 'darwin') {
|
|
286
296
|
await execFileAsync('sudo', ['route', '-n', 'delete', '-inet6', destination]);
|
|
287
297
|
}
|
|
288
|
-
else if (
|
|
298
|
+
else if (PLATFORM === 'linux') {
|
|
289
299
|
await execFileAsync('sudo', ['ip', '-6', 'route', 'del', destination, 'dev', this.name]);
|
|
290
300
|
}
|
|
291
301
|
else {
|
|
292
|
-
throw new TunTapError(`Unsupported platform: ${
|
|
302
|
+
throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
|
|
293
303
|
}
|
|
294
304
|
}
|
|
295
305
|
catch (err) {
|
|
296
|
-
|
|
297
|
-
|
|
306
|
+
const message = err.message;
|
|
307
|
+
if (message.includes('not in table') || message.includes('No such process')) {
|
|
308
|
+
return;
|
|
298
309
|
}
|
|
310
|
+
throw new TunTapError(`Failed to remove route: ${message}`);
|
|
299
311
|
}
|
|
300
312
|
}
|
|
301
313
|
/**
|
|
302
|
-
* Get interface statistics
|
|
314
|
+
* Get interface statistics.
|
|
303
315
|
*/
|
|
304
316
|
async getStats() {
|
|
305
|
-
|
|
306
|
-
throw new TunTapError('Device not open');
|
|
307
|
-
}
|
|
308
|
-
const platform = process.platform;
|
|
317
|
+
this.assertReady();
|
|
309
318
|
try {
|
|
310
|
-
if (
|
|
311
|
-
|
|
312
|
-
const lines = stdout.trim().split('\n');
|
|
313
|
-
if (lines.length < 2) {
|
|
314
|
-
throw new Error('Unexpected netstat output');
|
|
315
|
-
}
|
|
316
|
-
const stats = lines[1].split(/\s+/);
|
|
317
|
-
return {
|
|
318
|
-
rxPackets: parseInt(stats[4], 10) || 0,
|
|
319
|
-
rxErrors: parseInt(stats[5], 10) || 0,
|
|
320
|
-
rxBytes: parseInt(stats[6], 10) || 0,
|
|
321
|
-
txPackets: parseInt(stats[7], 10) || 0,
|
|
322
|
-
txErrors: parseInt(stats[8], 10) || 0,
|
|
323
|
-
txBytes: parseInt(stats[9], 10) || 0,
|
|
324
|
-
};
|
|
319
|
+
if (PLATFORM === 'darwin') {
|
|
320
|
+
return await this.getStatsDarwin();
|
|
325
321
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const lines = stdout.trim().split('\n');
|
|
329
|
-
let rxIndex = -1;
|
|
330
|
-
let txIndex = -1;
|
|
331
|
-
for (let i = 0; i < lines.length; i++) {
|
|
332
|
-
if (lines[i].includes('RX:')) {
|
|
333
|
-
rxIndex = i + 1;
|
|
334
|
-
}
|
|
335
|
-
if (lines[i].includes('TX:')) {
|
|
336
|
-
txIndex = i + 1;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
if (rxIndex === -1 || txIndex === -1) {
|
|
340
|
-
throw new Error('Could not parse interface statistics');
|
|
341
|
-
}
|
|
342
|
-
const rxStats = lines[rxIndex].trim().split(/\s+/);
|
|
343
|
-
const txStats = lines[txIndex].trim().split(/\s+/);
|
|
344
|
-
return {
|
|
345
|
-
rxBytes: parseInt(rxStats[0], 10) || 0,
|
|
346
|
-
rxPackets: parseInt(rxStats[1], 10) || 0,
|
|
347
|
-
rxErrors: parseInt(rxStats[2], 10) || 0,
|
|
348
|
-
txBytes: parseInt(txStats[0], 10) || 0,
|
|
349
|
-
txPackets: parseInt(txStats[1], 10) || 0,
|
|
350
|
-
txErrors: parseInt(txStats[2], 10) || 0,
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
else {
|
|
354
|
-
throw new TunTapError(`Unsupported platform: ${platform}`);
|
|
322
|
+
if (PLATFORM === 'linux') {
|
|
323
|
+
return await this.getStatsLinux();
|
|
355
324
|
}
|
|
325
|
+
throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
|
|
356
326
|
}
|
|
357
327
|
catch (err) {
|
|
328
|
+
if (err instanceof TunTapError) {
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
358
331
|
throw new TunTapError(`Failed to get interface statistics: ${err.message}`);
|
|
359
332
|
}
|
|
360
333
|
}
|
|
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');
|
|
362
|
+
}
|
|
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');
|
|
367
|
+
}
|
|
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
|
+
}
|
|
361
377
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { TunTap } from './TunTap.js';
|
|
1
|
+
export { TunTap, type PacketCallback } from './TunTap.js';
|
|
2
2
|
export * from './tunnel.js';
|
package/lib/tunnel.js
CHANGED
|
@@ -2,44 +2,6 @@ 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
|
-
// Global registry for active tunnel managers
|
|
6
|
-
const activeTunnelManagers = new Set();
|
|
7
|
-
// Setup process signal handlers
|
|
8
|
-
let signalHandlersSetup = false;
|
|
9
|
-
function setupSignalHandlers() {
|
|
10
|
-
if (signalHandlersSetup) {
|
|
11
|
-
return;
|
|
12
|
-
}
|
|
13
|
-
signalHandlersSetup = true;
|
|
14
|
-
const gracefulShutdown = async (signal) => {
|
|
15
|
-
log.debug(`Received ${signal}, initiating graceful shutdown...`);
|
|
16
|
-
// Copy the set to avoid modification during iteration
|
|
17
|
-
const managers = Array.from(activeTunnelManagers);
|
|
18
|
-
// Stop all tunnel managers
|
|
19
|
-
await Promise.all(managers.map((manager) => {
|
|
20
|
-
try {
|
|
21
|
-
return manager.stop();
|
|
22
|
-
}
|
|
23
|
-
catch (err) {
|
|
24
|
-
log.error('Error stopping tunnel manager:', err);
|
|
25
|
-
}
|
|
26
|
-
}));
|
|
27
|
-
log.debug('All tunnel managers stopped, exiting...');
|
|
28
|
-
process.exit(0);
|
|
29
|
-
};
|
|
30
|
-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
31
|
-
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
32
|
-
// Handle uncaught exceptions
|
|
33
|
-
process.on('uncaughtException', async (err) => {
|
|
34
|
-
log.error('Uncaught exception:', err);
|
|
35
|
-
await gracefulShutdown('uncaughtException');
|
|
36
|
-
process.exit(1);
|
|
37
|
-
});
|
|
38
|
-
// Handle unhandled promise rejections
|
|
39
|
-
process.on('unhandledRejection', (reason, promise) => {
|
|
40
|
-
log.error(`Unhandled rejection at: ${promise} reason: ${reason}`);
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
5
|
export class TunnelManager extends EventEmitter {
|
|
44
6
|
tun;
|
|
45
7
|
cancelled;
|
|
@@ -59,10 +21,6 @@ export class TunnelManager extends EventEmitter {
|
|
|
59
21
|
this.packetQueue = [];
|
|
60
22
|
this.deviceConn = null;
|
|
61
23
|
this.cleanupPromise = null;
|
|
62
|
-
// Setup signal handlers on first tunnel manager creation
|
|
63
|
-
setupSignalHandlers();
|
|
64
|
-
// Register this manager
|
|
65
|
-
activeTunnelManagers.add(this);
|
|
66
24
|
}
|
|
67
25
|
addPacketConsumer(consumer) {
|
|
68
26
|
this.packetConsumers.add(consumer);
|
|
@@ -364,8 +322,6 @@ export class TunnelManager extends EventEmitter {
|
|
|
364
322
|
}
|
|
365
323
|
this.tun = null;
|
|
366
324
|
}
|
|
367
|
-
// Unregister from active managers
|
|
368
|
-
activeTunnelManagers.delete(this);
|
|
369
325
|
log.debug(`Tunnel for ${tunName} closed successfully`);
|
|
370
326
|
}
|
|
371
327
|
}
|
package/package.json
CHANGED
package/src/tuntap.cc
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
#include <memory>
|
|
10
10
|
#include <mutex>
|
|
11
11
|
#include <atomic>
|
|
12
|
-
#include <csignal>
|
|
13
12
|
#include <uv.h>
|
|
14
13
|
|
|
15
14
|
#ifdef __APPLE__
|
|
@@ -20,17 +19,14 @@
|
|
|
20
19
|
#include <netinet/in.h>
|
|
21
20
|
#include <netinet6/in6_var.h>
|
|
22
21
|
#define UTUN_CONTROL_NAME "com.apple.net.utun_control"
|
|
22
|
+
#define UTUN_HEADER_SIZE 4
|
|
23
23
|
#else
|
|
24
24
|
#include <linux/if.h>
|
|
25
25
|
#include <linux/if_tun.h>
|
|
26
26
|
#include <sys/stat.h>
|
|
27
|
+
#define UTUN_HEADER_SIZE 0
|
|
27
28
|
#endif
|
|
28
29
|
|
|
29
|
-
// Global state for signal handling
|
|
30
|
-
static std::atomic<bool> g_shutdown_requested(false);
|
|
31
|
-
static std::mutex g_devices_mutex;
|
|
32
|
-
static std::vector<class TunDevice*> g_active_devices;
|
|
33
|
-
|
|
34
30
|
// RAII wrapper for file descriptors
|
|
35
31
|
class FileDescriptor {
|
|
36
32
|
private:
|
|
@@ -46,11 +42,9 @@ public:
|
|
|
46
42
|
}
|
|
47
43
|
}
|
|
48
44
|
|
|
49
|
-
// Disable copy
|
|
50
45
|
FileDescriptor(const FileDescriptor&) = delete;
|
|
51
46
|
FileDescriptor& operator=(const FileDescriptor&) = delete;
|
|
52
47
|
|
|
53
|
-
// Enable move
|
|
54
48
|
FileDescriptor(FileDescriptor&& other) noexcept : fd_(other.fd_) {
|
|
55
49
|
other.fd_ = -1;
|
|
56
50
|
}
|
|
@@ -90,14 +84,11 @@ public:
|
|
|
90
84
|
TunDevice(const Napi::CallbackInfo& info);
|
|
91
85
|
~TunDevice();
|
|
92
86
|
|
|
93
|
-
private:
|
|
94
|
-
static Napi::FunctionReference constructor;
|
|
95
|
-
static std::once_flag signal_handler_flag;
|
|
96
|
-
|
|
97
|
-
public:
|
|
98
87
|
void CloseInternal();
|
|
99
88
|
|
|
100
89
|
private:
|
|
90
|
+
static Napi::FunctionReference constructor;
|
|
91
|
+
|
|
101
92
|
Napi::Value Open(const Napi::CallbackInfo& info);
|
|
102
93
|
Napi::Value Close(const Napi::CallbackInfo& info);
|
|
103
94
|
Napi::Value Read(const Napi::CallbackInfo& info);
|
|
@@ -113,9 +104,9 @@ private:
|
|
|
113
104
|
|
|
114
105
|
uv_poll_t* poll_handle_ = nullptr;
|
|
115
106
|
Napi::ThreadSafeFunction tsfn_;
|
|
107
|
+
static constexpr size_t MAX_POLL_BUFFER = 65535;
|
|
108
|
+
size_t poll_buffer_size_ = MAX_POLL_BUFFER;
|
|
116
109
|
|
|
117
|
-
void RegisterDevice();
|
|
118
|
-
void UnregisterDevice();
|
|
119
110
|
void StopPolling();
|
|
120
111
|
static void PollCallback(uv_poll_t* handle, int status, int events);
|
|
121
112
|
};
|
|
@@ -157,19 +148,6 @@ TunDevice::~TunDevice() {
|
|
|
157
148
|
CloseInternal();
|
|
158
149
|
}
|
|
159
150
|
|
|
160
|
-
void TunDevice::RegisterDevice() {
|
|
161
|
-
std::lock_guard<std::mutex> lock(g_devices_mutex);
|
|
162
|
-
g_active_devices.push_back(this);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
void TunDevice::UnregisterDevice() {
|
|
166
|
-
std::lock_guard<std::mutex> lock(g_devices_mutex);
|
|
167
|
-
g_active_devices.erase(
|
|
168
|
-
std::remove(g_active_devices.begin(), g_active_devices.end(), this),
|
|
169
|
-
g_active_devices.end()
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
151
|
void TunDevice::CloseInternal() {
|
|
174
152
|
if (is_open_.exchange(false)) {
|
|
175
153
|
StopPolling();
|
|
@@ -185,21 +163,15 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
|
|
|
185
163
|
return Napi::Boolean::New(env, true);
|
|
186
164
|
}
|
|
187
165
|
|
|
188
|
-
if (g_shutdown_requested.load()) {
|
|
189
|
-
Napi::Error::New(env, "Shutdown in progress").ThrowAsJavaScriptException();
|
|
190
|
-
return Napi::Boolean::New(env, false);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
166
|
#ifdef __APPLE__
|
|
194
|
-
// macOS
|
|
167
|
+
// macOS: create utun interface via PF_SYSTEM control socket
|
|
195
168
|
struct ctl_info ctlInfo;
|
|
196
169
|
struct sockaddr_ctl sc;
|
|
197
170
|
|
|
198
171
|
FileDescriptor temp_fd(socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL));
|
|
199
172
|
if (!temp_fd.is_valid()) {
|
|
200
|
-
std::string
|
|
201
|
-
|
|
202
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
173
|
+
Napi::Error::New(env, std::string("Failed to create control socket: ") + strerror(errno))
|
|
174
|
+
.ThrowAsJavaScriptException();
|
|
203
175
|
return Napi::Boolean::New(env, false);
|
|
204
176
|
}
|
|
205
177
|
|
|
@@ -208,9 +180,8 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
|
|
|
208
180
|
ctlInfo.ctl_name[sizeof(ctlInfo.ctl_name) - 1] = '\0';
|
|
209
181
|
|
|
210
182
|
if (ioctl(temp_fd.get(), CTLIOCGINFO, &ctlInfo) < 0) {
|
|
211
|
-
std::string
|
|
212
|
-
|
|
213
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
183
|
+
Napi::Error::New(env, std::string("Failed to get utun control info: ") + strerror(errno))
|
|
184
|
+
.ThrowAsJavaScriptException();
|
|
214
185
|
return Napi::Boolean::New(env, false);
|
|
215
186
|
}
|
|
216
187
|
|
|
@@ -220,7 +191,7 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
|
|
|
220
191
|
sc.ss_sysaddr = SYSPROTO_CONTROL;
|
|
221
192
|
sc.sc_id = ctlInfo.ctl_id;
|
|
222
193
|
|
|
223
|
-
// Parse utun number if provided, otherwise
|
|
194
|
+
// Parse utun number if provided, otherwise auto-select (utun0 = unit 1)
|
|
224
195
|
int utun_unit = 0;
|
|
225
196
|
if (!name_.empty() && name_.find("utun") == 0) {
|
|
226
197
|
try {
|
|
@@ -232,24 +203,20 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
|
|
|
232
203
|
|
|
233
204
|
if (utun_unit > 0) {
|
|
234
205
|
sc.sc_unit = utun_unit;
|
|
235
|
-
// Try to connect with the specified unit
|
|
236
206
|
if (connect(temp_fd.get(), (struct sockaddr*)&sc, sizeof(sc)) < 0) {
|
|
237
|
-
std::string
|
|
238
|
-
|
|
239
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
207
|
+
Napi::Error::New(env, std::string("Failed to connect to utun with specified unit: ") + strerror(errno))
|
|
208
|
+
.ThrowAsJavaScriptException();
|
|
240
209
|
return Napi::Boolean::New(env, false);
|
|
241
210
|
}
|
|
242
211
|
} else {
|
|
243
|
-
// Find the first available unit
|
|
244
212
|
bool connected = false;
|
|
245
213
|
for (sc.sc_unit = 1; sc.sc_unit < 255; sc.sc_unit++) {
|
|
246
214
|
if (connect(temp_fd.get(), (struct sockaddr*)&sc, sizeof(sc)) == 0) {
|
|
247
215
|
connected = true;
|
|
248
216
|
break;
|
|
249
217
|
} else if (errno != EBUSY) {
|
|
250
|
-
std::string
|
|
251
|
-
|
|
252
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
218
|
+
Napi::Error::New(env, std::string("Failed to connect to utun control socket: ") + strerror(errno))
|
|
219
|
+
.ThrowAsJavaScriptException();
|
|
253
220
|
return Napi::Boolean::New(env, false);
|
|
254
221
|
}
|
|
255
222
|
}
|
|
@@ -260,55 +227,49 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
|
|
|
260
227
|
}
|
|
261
228
|
}
|
|
262
229
|
|
|
263
|
-
// Get the utun device name
|
|
264
230
|
char utunname[20];
|
|
265
231
|
socklen_t utunname_len = sizeof(utunname);
|
|
266
232
|
if (getsockopt(temp_fd.get(), SYSPROTO_CONTROL, UTUN_OPT_IFNAME, utunname, &utunname_len) < 0) {
|
|
267
|
-
std::string
|
|
268
|
-
|
|
269
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
233
|
+
Napi::Error::New(env, std::string("Failed to get utun interface name: ") + strerror(errno))
|
|
234
|
+
.ThrowAsJavaScriptException();
|
|
270
235
|
return Napi::Boolean::New(env, false);
|
|
271
236
|
}
|
|
272
237
|
|
|
273
238
|
name_ = std::string(utunname);
|
|
274
239
|
|
|
275
240
|
#else
|
|
276
|
-
// Linux
|
|
277
|
-
// First check if /dev/net/tun exists
|
|
241
|
+
// Linux: create TUN device via /dev/net/tun
|
|
278
242
|
struct stat statbuf;
|
|
279
243
|
if (stat("/dev/net/tun", &statbuf) != 0) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
244
|
+
Napi::Error::New(env,
|
|
245
|
+
"TUN/TAP device not available: /dev/net/tun does not exist. "
|
|
246
|
+
"Please ensure the TUN/TAP kernel module is loaded (modprobe tun).")
|
|
247
|
+
.ThrowAsJavaScriptException();
|
|
283
248
|
return Napi::Boolean::New(env, false);
|
|
284
249
|
}
|
|
285
250
|
|
|
286
251
|
FileDescriptor temp_fd(open("/dev/net/tun", O_RDWR));
|
|
287
252
|
if (!temp_fd.is_valid()) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
253
|
+
Napi::Error::New(env,
|
|
254
|
+
std::string("Failed to open /dev/net/tun: ") + strerror(errno) +
|
|
255
|
+
". This usually means you don't have sufficient permissions. "
|
|
256
|
+
"Try running with sudo or add your user to the 'tun' group.")
|
|
257
|
+
.ThrowAsJavaScriptException();
|
|
293
258
|
return Napi::Boolean::New(env, false);
|
|
294
259
|
}
|
|
295
260
|
|
|
296
261
|
struct ifreq ifr;
|
|
297
262
|
memset(&ifr, 0, sizeof(ifr));
|
|
298
|
-
|
|
299
|
-
// Set flags - IFF_TUN for TUN device, IFF_NO_PI to not provide packet info
|
|
300
263
|
ifr.ifr_flags = IFF_TUN | IFF_NO_PI;
|
|
301
264
|
|
|
302
|
-
// If name is provided, use it
|
|
303
265
|
if (!name_.empty()) {
|
|
304
266
|
strncpy(ifr.ifr_name, name_.c_str(), IFNAMSIZ - 1);
|
|
305
267
|
ifr.ifr_name[IFNAMSIZ - 1] = '\0';
|
|
306
268
|
}
|
|
307
269
|
|
|
308
270
|
if (ioctl(temp_fd.get(), TUNSETIFF, &ifr) < 0) {
|
|
309
|
-
std::string
|
|
310
|
-
|
|
311
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
271
|
+
Napi::Error::New(env, std::string("Failed to configure TUN device: ") + strerror(errno))
|
|
272
|
+
.ThrowAsJavaScriptException();
|
|
312
273
|
return Napi::Boolean::New(env, false);
|
|
313
274
|
}
|
|
314
275
|
|
|
@@ -318,20 +279,17 @@ Napi::Value TunDevice::Open(const Napi::CallbackInfo& info) {
|
|
|
318
279
|
// Set non-blocking mode
|
|
319
280
|
int flags = fcntl(temp_fd.get(), F_GETFL, 0);
|
|
320
281
|
if (flags < 0) {
|
|
321
|
-
std::string
|
|
322
|
-
|
|
323
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
282
|
+
Napi::Error::New(env, std::string("Failed to get file descriptor flags: ") + strerror(errno))
|
|
283
|
+
.ThrowAsJavaScriptException();
|
|
324
284
|
return Napi::Boolean::New(env, false);
|
|
325
285
|
}
|
|
326
286
|
|
|
327
287
|
if (fcntl(temp_fd.get(), F_SETFL, flags | O_NONBLOCK) < 0) {
|
|
328
|
-
std::string
|
|
329
|
-
|
|
330
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
288
|
+
Napi::Error::New(env, std::string("Failed to set non-blocking mode: ") + strerror(errno))
|
|
289
|
+
.ThrowAsJavaScriptException();
|
|
331
290
|
return Napi::Boolean::New(env, false);
|
|
332
291
|
}
|
|
333
292
|
|
|
334
|
-
// Transfer ownership to member variable
|
|
335
293
|
fd_ = std::move(temp_fd);
|
|
336
294
|
is_open_ = true;
|
|
337
295
|
|
|
@@ -354,63 +312,44 @@ Napi::Value TunDevice::Read(const Napi::CallbackInfo& info) {
|
|
|
354
312
|
return env.Null();
|
|
355
313
|
}
|
|
356
314
|
|
|
357
|
-
|
|
358
|
-
return Napi::Buffer<uint8_t>::New(env, 0);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Read buffer size
|
|
362
|
-
size_t buffer_size = 4096; // Default
|
|
315
|
+
size_t buffer_size = 4096;
|
|
363
316
|
if (info.Length() > 0 && info[0].IsNumber()) {
|
|
364
317
|
buffer_size = info[0].As<Napi::Number>().Uint32Value();
|
|
318
|
+
if (buffer_size == 0 || buffer_size > MAX_POLL_BUFFER) {
|
|
319
|
+
Napi::RangeError::New(env, "Read buffer size must be between 1 and " + std::to_string(MAX_POLL_BUFFER)).ThrowAsJavaScriptException();
|
|
320
|
+
return env.Null();
|
|
321
|
+
}
|
|
365
322
|
}
|
|
366
323
|
|
|
367
|
-
// Create buffer for reading
|
|
368
|
-
Napi::Buffer<uint8_t> buffer = Napi::Buffer<uint8_t>::New(env, buffer_size);
|
|
369
|
-
uint8_t* data = buffer.Data();
|
|
370
|
-
|
|
371
324
|
#ifdef __APPLE__
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
ssize_t bytes_read = read(fd_.get(), tmp_buffer.data(), buffer_size + 4);
|
|
377
|
-
if (bytes_read <= 0) {
|
|
325
|
+
// macOS: reads include a 4-byte protocol family prefix that must be stripped
|
|
326
|
+
std::vector<uint8_t> raw(buffer_size + 4);
|
|
327
|
+
ssize_t n = read(fd_.get(), raw.data(), raw.size());
|
|
328
|
+
if (n <= 0) {
|
|
378
329
|
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
|
379
|
-
// No data available
|
|
380
330
|
return Napi::Buffer<uint8_t>::New(env, 0);
|
|
381
331
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
std::string error = "Read error: ";
|
|
385
|
-
error += strerror(errno);
|
|
386
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
332
|
+
Napi::Error::New(env, std::string("Read error: ") + strerror(errno))
|
|
333
|
+
.ThrowAsJavaScriptException();
|
|
387
334
|
return env.Null();
|
|
388
335
|
}
|
|
389
|
-
|
|
390
|
-
// Skip the 4-byte protocol family header
|
|
391
|
-
if (bytes_read > 4) {
|
|
392
|
-
memcpy(data, tmp_buffer.data() + 4, bytes_read - 4);
|
|
393
|
-
return Napi::Buffer<uint8_t>::Copy(env, data, bytes_read - 4);
|
|
394
|
-
} else {
|
|
336
|
+
if (n <= 4) {
|
|
395
337
|
return Napi::Buffer<uint8_t>::New(env, 0);
|
|
396
338
|
}
|
|
339
|
+
return Napi::Buffer<uint8_t>::Copy(env, raw.data() + 4, n - 4);
|
|
397
340
|
#else
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
341
|
+
// Linux: raw IP packets directly
|
|
342
|
+
std::vector<uint8_t> raw(buffer_size);
|
|
343
|
+
ssize_t n = read(fd_.get(), raw.data(), raw.size());
|
|
344
|
+
if (n < 0) {
|
|
401
345
|
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
|
402
|
-
// No data available
|
|
403
346
|
return Napi::Buffer<uint8_t>::New(env, 0);
|
|
404
347
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
std::string error = "Read error: ";
|
|
408
|
-
error += strerror(errno);
|
|
409
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
348
|
+
Napi::Error::New(env, std::string("Read error: ") + strerror(errno))
|
|
349
|
+
.ThrowAsJavaScriptException();
|
|
410
350
|
return env.Null();
|
|
411
351
|
}
|
|
412
|
-
|
|
413
|
-
return Napi::Buffer<uint8_t>::Copy(env, data, bytes_read);
|
|
352
|
+
return Napi::Buffer<uint8_t>::Copy(env, raw.data(), n);
|
|
414
353
|
#endif
|
|
415
354
|
}
|
|
416
355
|
|
|
@@ -423,11 +362,6 @@ Napi::Value TunDevice::Write(const Napi::CallbackInfo& info) {
|
|
|
423
362
|
return Napi::Number::New(env, -1);
|
|
424
363
|
}
|
|
425
364
|
|
|
426
|
-
if (g_shutdown_requested.load()) {
|
|
427
|
-
Napi::Error::New(env, "Shutdown in progress").ThrowAsJavaScriptException();
|
|
428
|
-
return Napi::Number::New(env, -1);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
365
|
if (info.Length() < 1 || !info[0].IsBuffer()) {
|
|
432
366
|
Napi::TypeError::New(env, "Expected buffer as first argument").ThrowAsJavaScriptException();
|
|
433
367
|
return Napi::Number::New(env, -1);
|
|
@@ -438,34 +372,26 @@ Napi::Value TunDevice::Write(const Napi::CallbackInfo& info) {
|
|
|
438
372
|
size_t length = buffer.Length();
|
|
439
373
|
|
|
440
374
|
#ifdef __APPLE__
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
std::vector<uint8_t> tmp_buffer(length + 4);
|
|
375
|
+
// macOS: prepend 4-byte AF_INET6 protocol family header
|
|
376
|
+
std::vector<uint8_t> frame(length + 4);
|
|
444
377
|
uint32_t family = htonl(AF_INET6);
|
|
378
|
+
memcpy(frame.data(), &family, 4);
|
|
379
|
+
memcpy(frame.data() + 4, data, length);
|
|
445
380
|
|
|
446
|
-
|
|
447
|
-
memcpy(tmp_buffer.data() + 4, data, length);
|
|
448
|
-
|
|
449
|
-
ssize_t bytes_written = write(fd_.get(), tmp_buffer.data(), length + 4);
|
|
381
|
+
ssize_t bytes_written = write(fd_.get(), frame.data(), frame.size());
|
|
450
382
|
if (bytes_written < 0) {
|
|
451
|
-
std::string
|
|
452
|
-
|
|
453
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
383
|
+
Napi::Error::New(env, std::string("Write error: ") + strerror(errno))
|
|
384
|
+
.ThrowAsJavaScriptException();
|
|
454
385
|
return Napi::Number::New(env, -1);
|
|
455
386
|
}
|
|
456
|
-
|
|
457
|
-
// Return the original data length without the header
|
|
458
387
|
return Napi::Number::New(env, bytes_written > 4 ? bytes_written - 4 : 0);
|
|
459
388
|
#else
|
|
460
|
-
// On Linux, we write directly from the buffer
|
|
461
389
|
ssize_t bytes_written = write(fd_.get(), data, length);
|
|
462
390
|
if (bytes_written < 0) {
|
|
463
|
-
std::string
|
|
464
|
-
|
|
465
|
-
Napi::Error::New(env, error).ThrowAsJavaScriptException();
|
|
391
|
+
Napi::Error::New(env, std::string("Write error: ") + strerror(errno))
|
|
392
|
+
.ThrowAsJavaScriptException();
|
|
466
393
|
return Napi::Number::New(env, -1);
|
|
467
394
|
}
|
|
468
|
-
|
|
469
395
|
return Napi::Number::New(env, bytes_written);
|
|
470
396
|
#endif
|
|
471
397
|
}
|
|
@@ -488,12 +414,25 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
|
|
|
488
414
|
Napi::Error::New(env, "Device not open").ThrowAsJavaScriptException();
|
|
489
415
|
return env.Null();
|
|
490
416
|
}
|
|
491
|
-
|
|
417
|
+
|
|
418
|
+
if (info.Length() < 1 || !info[0].IsFunction()) {
|
|
492
419
|
Napi::TypeError::New(env, "Expected function as first argument").ThrowAsJavaScriptException();
|
|
493
420
|
return env.Null();
|
|
494
421
|
}
|
|
422
|
+
|
|
495
423
|
StopPolling();
|
|
496
424
|
|
|
425
|
+
// Optional buffer size as second argument (default: MAX_POLL_BUFFER)
|
|
426
|
+
poll_buffer_size_ = MAX_POLL_BUFFER;
|
|
427
|
+
if (info.Length() > 1 && info[1].IsNumber()) {
|
|
428
|
+
auto size = info[1].As<Napi::Number>().Uint32Value();
|
|
429
|
+
if (size == 0 || size > MAX_POLL_BUFFER) {
|
|
430
|
+
Napi::RangeError::New(env, "Buffer size must be between 1 and " + std::to_string(MAX_POLL_BUFFER)).ThrowAsJavaScriptException();
|
|
431
|
+
return env.Null();
|
|
432
|
+
}
|
|
433
|
+
poll_buffer_size_ = size;
|
|
434
|
+
}
|
|
435
|
+
|
|
497
436
|
tsfn_ = Napi::ThreadSafeFunction::New(
|
|
498
437
|
env,
|
|
499
438
|
info[0].As<Napi::Function>(),
|
|
@@ -502,27 +441,46 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
|
|
|
502
441
|
1
|
|
503
442
|
);
|
|
504
443
|
|
|
444
|
+
uv_loop_t* loop = nullptr;
|
|
445
|
+
napi_status napi_st = napi_get_uv_event_loop(env, &loop);
|
|
446
|
+
if (napi_st != napi_ok || loop == nullptr) {
|
|
447
|
+
tsfn_.Release();
|
|
448
|
+
tsfn_ = nullptr;
|
|
449
|
+
Napi::Error::New(env, "Failed to acquire event loop").ThrowAsJavaScriptException();
|
|
450
|
+
return env.Null();
|
|
451
|
+
}
|
|
452
|
+
|
|
505
453
|
auto handle = std::make_unique<uv_poll_t>();
|
|
506
|
-
if (uv_poll_init(
|
|
507
|
-
|
|
508
|
-
|
|
454
|
+
if (uv_poll_init(loop, handle.get(), fd_.get()) != 0) {
|
|
455
|
+
tsfn_.Release();
|
|
456
|
+
tsfn_ = nullptr;
|
|
457
|
+
Napi::Error::New(env, "Failed to initialize poll handle").ThrowAsJavaScriptException();
|
|
458
|
+
return env.Null();
|
|
509
459
|
}
|
|
510
460
|
|
|
511
461
|
handle->data = this;
|
|
512
462
|
if (uv_poll_start(handle.get(), UV_READABLE, PollCallback) != 0) {
|
|
513
|
-
|
|
514
|
-
|
|
463
|
+
// Properly close the initialized-but-not-started handle
|
|
464
|
+
uv_close(reinterpret_cast<uv_handle_t*>(handle.release()), [](uv_handle_t* h) {
|
|
465
|
+
delete reinterpret_cast<uv_poll_t*>(h);
|
|
466
|
+
});
|
|
467
|
+
tsfn_.Release();
|
|
468
|
+
tsfn_ = nullptr;
|
|
469
|
+
Napi::Error::New(env, "Failed to start polling").ThrowAsJavaScriptException();
|
|
470
|
+
return env.Null();
|
|
515
471
|
}
|
|
516
472
|
|
|
517
473
|
poll_handle_ = handle.release();
|
|
518
|
-
|
|
519
474
|
return env.Undefined();
|
|
520
475
|
}
|
|
521
476
|
|
|
522
477
|
void TunDevice::StopPolling() {
|
|
523
478
|
if (poll_handle_) {
|
|
524
479
|
uv_poll_stop(poll_handle_);
|
|
525
|
-
|
|
480
|
+
// Must use uv_close before freeing a libuv handle
|
|
481
|
+
uv_close(reinterpret_cast<uv_handle_t*>(poll_handle_), [](uv_handle_t* handle) {
|
|
482
|
+
delete reinterpret_cast<uv_poll_t*>(handle);
|
|
483
|
+
});
|
|
526
484
|
poll_handle_ = nullptr;
|
|
527
485
|
}
|
|
528
486
|
if (tsfn_) {
|
|
@@ -534,6 +492,10 @@ void TunDevice::StopPolling() {
|
|
|
534
492
|
void TunDevice::PollCallback(uv_poll_t* handle, int status, int events) {
|
|
535
493
|
if (status < 0) {
|
|
536
494
|
fprintf(stderr, "tuntap poll error: %s\n", uv_strerror(status));
|
|
495
|
+
auto* self = static_cast<TunDevice*>(handle->data);
|
|
496
|
+
if (self) {
|
|
497
|
+
self->StopPolling();
|
|
498
|
+
}
|
|
537
499
|
return;
|
|
538
500
|
}
|
|
539
501
|
|
|
@@ -541,35 +503,36 @@ void TunDevice::PollCallback(uv_poll_t* handle, int status, int events) {
|
|
|
541
503
|
return;
|
|
542
504
|
}
|
|
543
505
|
|
|
544
|
-
|
|
506
|
+
auto* self = static_cast<TunDevice*>(handle->data);
|
|
545
507
|
if (!self || !self->is_open_.load() || !self->fd_.is_valid()) {
|
|
546
508
|
return;
|
|
547
509
|
}
|
|
548
510
|
|
|
549
|
-
std::vector<uint8_t> buffer(
|
|
511
|
+
std::vector<uint8_t> buffer(self->poll_buffer_size_ + UTUN_HEADER_SIZE);
|
|
550
512
|
ssize_t bytes_read = read(self->fd_.get(), buffer.data(), buffer.size());
|
|
551
513
|
|
|
552
|
-
if (bytes_read
|
|
514
|
+
if (bytes_read == 0) {
|
|
515
|
+
self->StopPolling();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (bytes_read < 0) {
|
|
553
519
|
if (errno != EAGAIN && errno != EWOULDBLOCK) {
|
|
554
|
-
|
|
520
|
+
fprintf(stderr, "tuntap read error: %s\n", strerror(errno));
|
|
521
|
+
self->StopPolling();
|
|
555
522
|
}
|
|
556
523
|
return;
|
|
557
524
|
}
|
|
558
525
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
526
|
+
if (bytes_read > UTUN_HEADER_SIZE) {
|
|
527
|
+
self->tsfn_.BlockingCall(
|
|
528
|
+
[buf = std::move(buffer), bytes_read](Napi::Env env, Napi::Function jsCallback) {
|
|
529
|
+
if (env == nullptr || jsCallback.IsEmpty()) return;
|
|
530
|
+
jsCallback.Call({ Napi::Buffer<uint8_t>::Copy(env, buf.data() + UTUN_HEADER_SIZE, bytes_read - UTUN_HEADER_SIZE) });
|
|
531
|
+
}
|
|
532
|
+
);
|
|
564
533
|
}
|
|
565
|
-
#else
|
|
566
|
-
self->tsfn_.BlockingCall([buffer = std::move(buffer), bytes_read](Napi::Env env, Napi::Function jsCallback) {
|
|
567
|
-
jsCallback.Call({ Napi::Buffer<uint8_t>::Copy(env, buffer.data(), bytes_read) });
|
|
568
|
-
});
|
|
569
|
-
#endif
|
|
570
534
|
}
|
|
571
535
|
|
|
572
|
-
// Module initialization
|
|
573
536
|
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
574
537
|
return TunDevice::Init(env, exports);
|
|
575
538
|
}
|