appium-ios-tuntap 0.1.5 → 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 +6 -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 +19 -3
- package/lib/TunTap.js +202 -183
- package/lib/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/tuntap.cc +123 -160
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.1.5](https://github.com/appium/appium-ios-tuntap/compare/v0.1.4...v0.1.5) (2026-04-09)
|
|
2
8
|
|
|
3
9
|
### Code Refactoring
|
|
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
|
|
17
|
+
private _isOpen;
|
|
18
|
+
private _isClosed;
|
|
18
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,26 +32,44 @@ 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
|
-
|
|
56
|
+
_isOpen;
|
|
57
|
+
_isClosed;
|
|
61
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
|
};
|
|
@@ -79,42 +78,59 @@ export class TunTap {
|
|
|
79
78
|
process.removeListener('exit', cleanup);
|
|
80
79
|
};
|
|
81
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
|
+
}
|
|
97
|
+
}
|
|
82
98
|
open() {
|
|
83
|
-
if (this.
|
|
99
|
+
if (this._isClosed) {
|
|
84
100
|
throw new TunTapError('Device has been closed and cannot be reopened');
|
|
85
101
|
}
|
|
86
|
-
if (!this.
|
|
102
|
+
if (!this._isOpen) {
|
|
87
103
|
try {
|
|
88
|
-
this.
|
|
89
|
-
if (!this.
|
|
104
|
+
this._isOpen = this.device.open();
|
|
105
|
+
if (!this._isOpen) {
|
|
90
106
|
throw new TunTapDeviceError('Failed to open TUN device');
|
|
91
107
|
}
|
|
92
108
|
}
|
|
93
109
|
catch (err) {
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
throw new TunTapPermissionError(
|
|
110
|
+
const message = err.message ?? '';
|
|
111
|
+
if (message.includes('Permission denied') || message.includes('sudo')) {
|
|
112
|
+
throw new TunTapPermissionError(message);
|
|
97
113
|
}
|
|
98
|
-
|
|
99
|
-
throw new TunTapDeviceError(
|
|
114
|
+
if (message.includes('not available') || message.includes('does not exist')) {
|
|
115
|
+
throw new TunTapDeviceError(message);
|
|
100
116
|
}
|
|
101
117
|
throw err;
|
|
102
118
|
}
|
|
103
119
|
}
|
|
104
|
-
return this.
|
|
120
|
+
return this._isOpen;
|
|
105
121
|
}
|
|
106
122
|
close() {
|
|
107
123
|
if (this.removeExitListener) {
|
|
108
124
|
this.removeExitListener();
|
|
109
125
|
this.removeExitListener = null;
|
|
110
126
|
}
|
|
111
|
-
if (!this.
|
|
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.
|
|
133
|
+
this._isClosed = true;
|
|
118
134
|
}
|
|
119
135
|
catch (err) {
|
|
120
136
|
throw new TunTapError(`Failed to close device: ${err.message}`);
|
|
@@ -122,15 +138,10 @@ export class TunTap {
|
|
|
122
138
|
}
|
|
123
139
|
return true;
|
|
124
140
|
}
|
|
125
|
-
read(maxSize =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (this.isClosed) {
|
|
130
|
-
throw new TunTapError('Device has been closed');
|
|
131
|
-
}
|
|
132
|
-
if (maxSize <= 0 || maxSize > 65536) {
|
|
133
|
-
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`);
|
|
134
145
|
}
|
|
135
146
|
try {
|
|
136
147
|
return this.device.read(maxSize);
|
|
@@ -140,20 +151,15 @@ export class TunTap {
|
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
write(data) {
|
|
143
|
-
|
|
144
|
-
throw new TunTapError('Device not open');
|
|
145
|
-
}
|
|
146
|
-
if (this.isClosed) {
|
|
147
|
-
throw new TunTapError('Device has been closed');
|
|
148
|
-
}
|
|
154
|
+
this.assertReady();
|
|
149
155
|
if (!Buffer.isBuffer(data)) {
|
|
150
156
|
throw new TypeError('Data must be a Buffer');
|
|
151
157
|
}
|
|
152
158
|
if (data.length === 0) {
|
|
153
159
|
return 0;
|
|
154
160
|
}
|
|
155
|
-
if (data.length >
|
|
156
|
-
throw new RangeError(
|
|
161
|
+
if (data.length > MAX_BUFFER_SIZE) {
|
|
162
|
+
throw new RangeError(`Write data too large (max ${MAX_BUFFER_SIZE} bytes)`);
|
|
157
163
|
}
|
|
158
164
|
try {
|
|
159
165
|
const result = this.device.write(data);
|
|
@@ -166,57 +172,44 @@ export class TunTap {
|
|
|
166
172
|
throw new TunTapError(`Write failed: ${err.message}`);
|
|
167
173
|
}
|
|
168
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
|
+
}
|
|
169
189
|
get name() {
|
|
170
190
|
return this.device.getName();
|
|
171
191
|
}
|
|
172
192
|
get fd() {
|
|
173
193
|
return this.device.getFd();
|
|
174
194
|
}
|
|
175
|
-
async configure(address, mtu =
|
|
176
|
-
|
|
177
|
-
throw new TunTapError('Device not open');
|
|
178
|
-
}
|
|
179
|
-
if (this.isClosed) {
|
|
180
|
-
throw new TunTapError('Device has been closed');
|
|
181
|
-
}
|
|
195
|
+
async configure(address, mtu = DEFAULT_MTU) {
|
|
196
|
+
this.assertReady();
|
|
182
197
|
if (!isIPv6(address)) {
|
|
183
198
|
throw new TypeError('Invalid IPv6 address format');
|
|
184
199
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
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}`);
|
|
188
202
|
}
|
|
189
|
-
const platform = process.platform;
|
|
190
203
|
try {
|
|
191
|
-
if (
|
|
204
|
+
if (PLATFORM === 'darwin') {
|
|
192
205
|
await execFileAsync('sudo', ['ifconfig', this.name, 'inet6', address, 'prefixlen', '64', 'up']);
|
|
193
206
|
await execFileAsync('sudo', ['ifconfig', this.name, 'mtu', String(mtu)]);
|
|
194
207
|
}
|
|
195
|
-
else if (
|
|
196
|
-
|
|
197
|
-
await execFileAsync('which', ['ip']);
|
|
198
|
-
}
|
|
199
|
-
catch {
|
|
200
|
-
throw new TunTapError('The "ip" command is not available. Please install the iproute2 package (e.g., sudo apt install iproute2)');
|
|
201
|
-
}
|
|
202
|
-
try {
|
|
203
|
-
await execFileAsync('sudo', ['ip', '-6', 'addr', 'add', `${address}/64`, 'dev', this.name]);
|
|
204
|
-
await execFileAsync('sudo', ['ip', 'link', 'set', 'dev', this.name, 'up', 'mtu', String(mtu)]);
|
|
205
|
-
}
|
|
206
|
-
catch (err) {
|
|
207
|
-
if (err.message.includes('Permission denied')) {
|
|
208
|
-
throw new TunTapPermissionError(`Permission denied when configuring network interface. Make sure you have sudo privileges or run the application with sudo.`);
|
|
209
|
-
}
|
|
210
|
-
else if (err.message.includes('File exists')) {
|
|
211
|
-
log.warn(`Address ${address} may already be configured on ${this.name}`);
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
throw err;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
208
|
+
else if (PLATFORM === 'linux') {
|
|
209
|
+
await this.configureLinux(address, mtu);
|
|
217
210
|
}
|
|
218
211
|
else {
|
|
219
|
-
throw new TunTapError(`Unsupported platform: ${
|
|
212
|
+
throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
|
|
220
213
|
}
|
|
221
214
|
}
|
|
222
215
|
catch (err) {
|
|
@@ -226,133 +219,159 @@ export class TunTap {
|
|
|
226
219
|
throw new TunTapError(`Failed to configure TUN interface: ${err.message}`);
|
|
227
220
|
}
|
|
228
221
|
}
|
|
229
|
-
async
|
|
230
|
-
|
|
231
|
-
|
|
222
|
+
async configureLinux(address, mtu) {
|
|
223
|
+
try {
|
|
224
|
+
await execFileAsync('which', ['ip']);
|
|
232
225
|
}
|
|
233
|
-
|
|
234
|
-
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');
|
|
235
248
|
}
|
|
236
249
|
if (!isValidIPv6Route(destination)) {
|
|
237
|
-
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)');
|
|
238
251
|
}
|
|
239
|
-
const platform = process.platform;
|
|
240
252
|
try {
|
|
241
|
-
if (
|
|
253
|
+
if (PLATFORM === 'darwin') {
|
|
242
254
|
await execFileAsync('sudo', ['route', '-n', 'add', '-inet6', destination, '-interface', this.name]);
|
|
243
255
|
}
|
|
244
|
-
else if (
|
|
245
|
-
|
|
246
|
-
await execFileAsync('sudo', ['ip', '-6', 'route', 'add', destination, 'dev', this.name]);
|
|
247
|
-
}
|
|
248
|
-
catch (err) {
|
|
249
|
-
if (err.message.includes('Permission denied')) {
|
|
250
|
-
throw new TunTapPermissionError(`Permission denied when adding route. Make sure you have sudo privileges or run the application with sudo.`);
|
|
251
|
-
}
|
|
252
|
-
else if (err.message.includes('File exists')) {
|
|
253
|
-
log.info(`Route to ${destination} already exists`);
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
throw err;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
256
|
+
else if (PLATFORM === 'linux') {
|
|
257
|
+
await this.addRouteLinux(destination);
|
|
259
258
|
}
|
|
260
259
|
else {
|
|
261
|
-
throw new TunTapError(`Unsupported platform: ${
|
|
260
|
+
throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
|
|
262
261
|
}
|
|
263
262
|
}
|
|
264
263
|
catch (err) {
|
|
265
264
|
if (err instanceof TunTapError) {
|
|
266
265
|
throw err;
|
|
267
266
|
}
|
|
268
|
-
|
|
269
|
-
|
|
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.');
|
|
278
|
+
}
|
|
279
|
+
if (message.includes('File exists')) {
|
|
280
|
+
log.info(`Route to ${destination} already exists`);
|
|
281
|
+
return;
|
|
270
282
|
}
|
|
283
|
+
throw err;
|
|
271
284
|
}
|
|
272
285
|
}
|
|
273
286
|
async removeRoute(destination) {
|
|
274
|
-
|
|
275
|
-
|
|
287
|
+
this.assertReady();
|
|
288
|
+
if (!destination || typeof destination !== 'string') {
|
|
289
|
+
throw new TypeError('Destination must be a non-empty string');
|
|
276
290
|
}
|
|
277
291
|
if (!isValidIPv6Route(destination)) {
|
|
278
|
-
throw new TypeError('Destination must be a valid IPv6 address or
|
|
292
|
+
throw new TypeError('Destination must be a valid IPv6 address or CIDR');
|
|
279
293
|
}
|
|
280
|
-
const platform = process.platform;
|
|
281
294
|
try {
|
|
282
|
-
if (
|
|
295
|
+
if (PLATFORM === 'darwin') {
|
|
283
296
|
await execFileAsync('sudo', ['route', '-n', 'delete', '-inet6', destination]);
|
|
284
297
|
}
|
|
285
|
-
else if (
|
|
298
|
+
else if (PLATFORM === 'linux') {
|
|
286
299
|
await execFileAsync('sudo', ['ip', '-6', 'route', 'del', destination, 'dev', this.name]);
|
|
287
300
|
}
|
|
288
301
|
else {
|
|
289
|
-
throw new TunTapError(`Unsupported platform: ${
|
|
302
|
+
throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
|
|
290
303
|
}
|
|
291
304
|
}
|
|
292
305
|
catch (err) {
|
|
293
|
-
|
|
294
|
-
|
|
306
|
+
const message = err.message;
|
|
307
|
+
if (message.includes('not in table') || message.includes('No such process')) {
|
|
308
|
+
return;
|
|
295
309
|
}
|
|
310
|
+
throw new TunTapError(`Failed to remove route: ${message}`);
|
|
296
311
|
}
|
|
297
312
|
}
|
|
298
313
|
/**
|
|
299
|
-
* Get interface statistics
|
|
314
|
+
* Get interface statistics.
|
|
300
315
|
*/
|
|
301
316
|
async getStats() {
|
|
302
|
-
|
|
303
|
-
throw new TunTapError('Device not open');
|
|
304
|
-
}
|
|
305
|
-
const platform = process.platform;
|
|
317
|
+
this.assertReady();
|
|
306
318
|
try {
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
const lines = stdout.trim().split('\n');
|
|
310
|
-
if (lines.length < 2) {
|
|
311
|
-
throw new Error('Unexpected netstat output');
|
|
312
|
-
}
|
|
313
|
-
const stats = lines[1].split(/\s+/);
|
|
314
|
-
return {
|
|
315
|
-
rxPackets: parseInt(stats[4], 10) || 0,
|
|
316
|
-
rxErrors: parseInt(stats[5], 10) || 0,
|
|
317
|
-
rxBytes: parseInt(stats[6], 10) || 0,
|
|
318
|
-
txPackets: parseInt(stats[7], 10) || 0,
|
|
319
|
-
txErrors: parseInt(stats[8], 10) || 0,
|
|
320
|
-
txBytes: parseInt(stats[9], 10) || 0,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
else if (platform === 'linux') {
|
|
324
|
-
const { stdout } = await execFileAsync('ip', ['-s', 'link', 'show', this.name]);
|
|
325
|
-
const lines = stdout.trim().split('\n');
|
|
326
|
-
let rxIndex = -1;
|
|
327
|
-
let txIndex = -1;
|
|
328
|
-
for (let i = 0; i < lines.length; i++) {
|
|
329
|
-
if (lines[i].includes('RX:')) {
|
|
330
|
-
rxIndex = i + 1;
|
|
331
|
-
}
|
|
332
|
-
if (lines[i].includes('TX:')) {
|
|
333
|
-
txIndex = i + 1;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
if (rxIndex === -1 || txIndex === -1) {
|
|
337
|
-
throw new Error('Could not parse interface statistics');
|
|
338
|
-
}
|
|
339
|
-
const rxStats = lines[rxIndex].trim().split(/\s+/);
|
|
340
|
-
const txStats = lines[txIndex].trim().split(/\s+/);
|
|
341
|
-
return {
|
|
342
|
-
rxBytes: parseInt(rxStats[0], 10) || 0,
|
|
343
|
-
rxPackets: parseInt(rxStats[1], 10) || 0,
|
|
344
|
-
rxErrors: parseInt(rxStats[2], 10) || 0,
|
|
345
|
-
txBytes: parseInt(txStats[0], 10) || 0,
|
|
346
|
-
txPackets: parseInt(txStats[1], 10) || 0,
|
|
347
|
-
txErrors: parseInt(txStats[2], 10) || 0,
|
|
348
|
-
};
|
|
319
|
+
if (PLATFORM === 'darwin') {
|
|
320
|
+
return await this.getStatsDarwin();
|
|
349
321
|
}
|
|
350
|
-
|
|
351
|
-
|
|
322
|
+
if (PLATFORM === 'linux') {
|
|
323
|
+
return await this.getStatsLinux();
|
|
352
324
|
}
|
|
325
|
+
throw new TunTapError(`Unsupported platform: ${PLATFORM}`);
|
|
353
326
|
}
|
|
354
327
|
catch (err) {
|
|
328
|
+
if (err instanceof TunTapError) {
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
355
331
|
throw new TunTapError(`Failed to get interface statistics: ${err.message}`);
|
|
356
332
|
}
|
|
357
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
|
+
}
|
|
358
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/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
|
}
|