@push.rocks/smartproxy 3.28.6 → 3.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,43 +3,100 @@ import { promisify } from 'util';
3
3
 
4
4
  const execAsync = promisify(exec);
5
5
 
6
+ /**
7
+ * Represents a port range for forwarding
8
+ */
9
+ export interface IPortRange {
10
+ from: number;
11
+ to: number;
12
+ }
13
+
6
14
  /**
7
15
  * Settings for IPTablesProxy.
8
16
  */
9
17
  export interface IIpTableProxySettings {
10
- fromPort: number;
11
- toPort: number;
18
+ // Basic settings
19
+ fromPort: number | IPortRange | Array<number | IPortRange>; // Support single port, port range, or multiple ports/ranges
20
+ toPort: number | IPortRange | Array<number | IPortRange>;
12
21
  toHost?: string; // Target host for proxying; defaults to 'localhost'
13
- preserveSourceIP?: boolean; // If true, the original source IP is preserved.
14
- deleteOnExit?: boolean; // If true, clean up marked iptables rules before process exit.
22
+
23
+ // Advanced settings
24
+ preserveSourceIP?: boolean; // If true, the original source IP is preserved
25
+ deleteOnExit?: boolean; // If true, clean up marked iptables rules before process exit
26
+ protocol?: 'tcp' | 'udp' | 'all'; // Protocol to forward, defaults to 'tcp'
27
+ enableLogging?: boolean; // Enable detailed logging
28
+ ipv6Support?: boolean; // Enable IPv6 support (ip6tables)
29
+
30
+ // Source filtering
31
+ allowedSourceIPs?: string[]; // If provided, only these IPs are allowed
32
+ bannedSourceIPs?: string[]; // If provided, these IPs are blocked
33
+
34
+ // Rule management
35
+ forceCleanSlate?: boolean; // Clear all IPTablesProxy rules before starting
36
+ addJumpRule?: boolean; // Add a custom chain for cleaner rule management
37
+ checkExistingRules?: boolean; // Check if rules already exist before adding
38
+
39
+ // Integration with PortProxy/NetworkProxy
40
+ netProxyIntegration?: {
41
+ enabled: boolean;
42
+ redirectLocalhost?: boolean; // Redirect localhost traffic to NetworkProxy
43
+ sslTerminationPort?: number; // Port where NetworkProxy handles SSL termination
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Represents a rule added to iptables
49
+ */
50
+ interface IpTablesRule {
51
+ table: string;
52
+ chain: string;
53
+ command: string;
54
+ tag: string;
55
+ added: boolean;
15
56
  }
16
57
 
17
58
  /**
18
59
  * IPTablesProxy sets up iptables NAT rules to forward TCP traffic.
19
- * It only supports basic port forwarding and uses iptables comments to tag rules.
60
+ * Enhanced with multi-port support, IPv6, and integration with PortProxy/NetworkProxy.
20
61
  */
21
62
  export class IPTablesProxy {
22
63
  public settings: IIpTableProxySettings;
23
- private rulesInstalled: boolean = false;
64
+ private rules: IpTablesRule[] = [];
24
65
  private ruleTag: string;
66
+ private customChain: string | null = null;
25
67
 
26
68
  constructor(settings: IIpTableProxySettings) {
69
+ // Validate inputs to prevent command injection
70
+ this.validateSettings(settings);
71
+
72
+ // Set default settings
27
73
  this.settings = {
28
74
  ...settings,
29
75
  toHost: settings.toHost || 'localhost',
76
+ protocol: settings.protocol || 'tcp',
77
+ enableLogging: settings.enableLogging !== undefined ? settings.enableLogging : false,
78
+ ipv6Support: settings.ipv6Support !== undefined ? settings.ipv6Support : false,
79
+ checkExistingRules: settings.checkExistingRules !== undefined ? settings.checkExistingRules : true,
80
+ netProxyIntegration: settings.netProxyIntegration || { enabled: false }
30
81
  };
31
- // Generate a unique identifier for the rules added by this instance.
82
+
83
+ // Generate a unique identifier for the rules added by this instance
32
84
  this.ruleTag = `IPTablesProxy:${Date.now()}:${Math.random().toString(36).substr(2, 5)}`;
85
+
86
+ if (this.settings.addJumpRule) {
87
+ this.customChain = `IPTablesProxy_${Math.random().toString(36).substr(2, 5)}`;
88
+ }
33
89
 
34
- // If deleteOnExit is true, register cleanup handlers.
90
+ // Register cleanup handlers if deleteOnExit is true
35
91
  if (this.settings.deleteOnExit) {
36
92
  const cleanup = () => {
37
93
  try {
38
- IPTablesProxy.cleanSlateSync();
94
+ this.stopSync();
39
95
  } catch (err) {
40
96
  console.error('Error cleaning iptables rules on exit:', err);
41
97
  }
42
98
  };
99
+
43
100
  process.on('exit', cleanup);
44
101
  process.on('SIGINT', () => {
45
102
  cleanup();
@@ -53,76 +110,591 @@ export class IPTablesProxy {
53
110
  }
54
111
 
55
112
  /**
56
- * Sets up iptables rules for port forwarding.
57
- * The rules are tagged with a unique comment so that they can be identified later.
113
+ * Validates settings to prevent command injection and ensure valid values
58
114
  */
59
- public async start(): Promise<void> {
60
- const dnatCmd = `iptables -t nat -A PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
61
- `-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
62
- `-m comment --comment "${this.ruleTag}:DNAT"`;
115
+ private validateSettings(settings: IIpTableProxySettings): void {
116
+ // Validate port numbers
117
+ const validatePorts = (port: number | IPortRange | Array<number | IPortRange>) => {
118
+ if (Array.isArray(port)) {
119
+ port.forEach(p => validatePorts(p));
120
+ return;
121
+ }
122
+
123
+ if (typeof port === 'number') {
124
+ if (port < 1 || port > 65535) {
125
+ throw new Error(`Invalid port number: ${port}`);
126
+ }
127
+ } else if (typeof port === 'object') {
128
+ if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
129
+ throw new Error(`Invalid port range: ${port.from}-${port.to}`);
130
+ }
131
+ }
132
+ };
133
+
134
+ validatePorts(settings.fromPort);
135
+ validatePorts(settings.toPort);
136
+
137
+ // Define regex patterns at the method level so they're available throughout
138
+ const ipRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
139
+ const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
140
+
141
+ // Validate IP addresses
142
+ const validateIPs = (ips?: string[]) => {
143
+ if (!ips) return;
144
+
145
+ for (const ip of ips) {
146
+ if (!ipRegex.test(ip) && !ipv6Regex.test(ip)) {
147
+ throw new Error(`Invalid IP address format: ${ip}`);
148
+ }
149
+ }
150
+ };
151
+
152
+ validateIPs(settings.allowedSourceIPs);
153
+ validateIPs(settings.bannedSourceIPs);
154
+
155
+ // Validate toHost - only allow hostnames or IPs
156
+ if (settings.toHost) {
157
+ const hostRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
158
+ if (!hostRegex.test(settings.toHost) && !ipRegex.test(settings.toHost) && !ipv6Regex.test(settings.toHost)) {
159
+ throw new Error(`Invalid host format: ${settings.toHost}`);
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Normalizes port specifications into an array of port ranges
166
+ */
167
+ private normalizePortSpec(portSpec: number | IPortRange | Array<number | IPortRange>): IPortRange[] {
168
+ const result: IPortRange[] = [];
169
+
170
+ if (Array.isArray(portSpec)) {
171
+ // If it's an array, process each element
172
+ for (const spec of portSpec) {
173
+ result.push(...this.normalizePortSpec(spec));
174
+ }
175
+ } else if (typeof portSpec === 'number') {
176
+ // Single port becomes a range with the same start and end
177
+ result.push({ from: portSpec, to: portSpec });
178
+ } else {
179
+ // Already a range
180
+ result.push(portSpec);
181
+ }
182
+
183
+ return result;
184
+ }
185
+
186
+ /**
187
+ * Gets the appropriate iptables command based on settings
188
+ */
189
+ private getIptablesCommand(isIpv6: boolean = false): string {
190
+ return isIpv6 ? 'ip6tables' : 'iptables';
191
+ }
192
+
193
+ /**
194
+ * Checks if a rule already exists in iptables
195
+ */
196
+ private async ruleExists(table: string, command: string, isIpv6: boolean = false): Promise<boolean> {
63
197
  try {
64
- await execAsync(dnatCmd);
65
- console.log(`Added iptables rule: ${dnatCmd}`);
66
- this.rulesInstalled = true;
198
+ const iptablesCmd = this.getIptablesCommand(isIpv6);
199
+ const { stdout } = await execAsync(`${iptablesCmd}-save -t ${table}`);
200
+ // Convert the command to the format found in iptables-save output
201
+ // (This is a simplification - in reality, you'd need more parsing)
202
+ const rulePattern = command.replace(`${iptablesCmd} -t ${table} -A `, '-A ');
203
+ return stdout.split('\n').some(line => line.trim() === rulePattern);
67
204
  } catch (err) {
68
- console.error(`Failed to add iptables DNAT rule: ${err}`);
69
- throw err;
205
+ this.log('error', `Failed to check if rule exists: ${err}`);
206
+ return false;
70
207
  }
208
+ }
71
209
 
72
- // If preserveSourceIP is false, add a MASQUERADE rule.
73
- if (!this.settings.preserveSourceIP) {
74
- const masqueradeCmd = `iptables -t nat -A POSTROUTING -p tcp -d ${this.settings.toHost} ` +
75
- `--dport ${this.settings.toPort} -j MASQUERADE ` +
76
- `-m comment --comment "${this.ruleTag}:MASQ"`;
77
- try {
78
- await execAsync(masqueradeCmd);
79
- console.log(`Added iptables rule: ${masqueradeCmd}`);
80
- } catch (err) {
81
- console.error(`Failed to add iptables MASQUERADE rule: ${err}`);
82
- // Roll back the DNAT rule if MASQUERADE fails.
83
- try {
84
- const rollbackCmd = `iptables -t nat -D PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
85
- `-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
86
- `-m comment --comment "${this.ruleTag}:DNAT"`;
87
- await execAsync(rollbackCmd);
88
- this.rulesInstalled = false;
89
- } catch (rollbackErr) {
90
- console.error(`Rollback failed: ${rollbackErr}`);
210
+ /**
211
+ * Sets up a custom chain for better rule management
212
+ */
213
+ private async setupCustomChain(isIpv6: boolean = false): Promise<boolean> {
214
+ if (!this.customChain) return true;
215
+
216
+ const iptablesCmd = this.getIptablesCommand(isIpv6);
217
+ const table = 'nat';
218
+
219
+ try {
220
+ // Create the chain
221
+ await execAsync(`${iptablesCmd} -t ${table} -N ${this.customChain}`);
222
+ this.log('info', `Created custom chain: ${this.customChain}`);
223
+
224
+ // Add jump rule to PREROUTING chain
225
+ const jumpCommand = `${iptablesCmd} -t ${table} -A PREROUTING -j ${this.customChain} -m comment --comment "${this.ruleTag}:JUMP"`;
226
+ await execAsync(jumpCommand);
227
+ this.log('info', `Added jump rule to ${this.customChain}`);
228
+
229
+ // Store the jump rule
230
+ this.rules.push({
231
+ table,
232
+ chain: 'PREROUTING',
233
+ command: jumpCommand,
234
+ tag: `${this.ruleTag}:JUMP`,
235
+ added: true
236
+ });
237
+
238
+ return true;
239
+ } catch (err) {
240
+ this.log('error', `Failed to set up custom chain: ${err}`);
241
+ return false;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Add a source IP filter rule
247
+ */
248
+ private async addSourceIPFilter(isIpv6: boolean = false): Promise<boolean> {
249
+ if (!this.settings.allowedSourceIPs && !this.settings.bannedSourceIPs) {
250
+ return true;
251
+ }
252
+
253
+ const iptablesCmd = this.getIptablesCommand(isIpv6);
254
+ const table = 'nat';
255
+ const chain = this.customChain || 'PREROUTING';
256
+
257
+ try {
258
+ // Add banned IPs first (explicit deny)
259
+ if (this.settings.bannedSourceIPs && this.settings.bannedSourceIPs.length > 0) {
260
+ for (const ip of this.settings.bannedSourceIPs) {
261
+ const command = `${iptablesCmd} -t ${table} -A ${chain} -s ${ip} -j DROP -m comment --comment "${this.ruleTag}:BANNED"`;
262
+
263
+ // Check if rule already exists
264
+ if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
265
+ this.log('info', `Rule already exists, skipping: ${command}`);
266
+ continue;
267
+ }
268
+
269
+ await execAsync(command);
270
+ this.log('info', `Added banned IP rule: ${command}`);
271
+
272
+ this.rules.push({
273
+ table,
274
+ chain,
275
+ command,
276
+ tag: `${this.ruleTag}:BANNED`,
277
+ added: true
278
+ });
91
279
  }
92
- throw err;
93
280
  }
281
+
282
+ // Add allowed IPs (explicit allow)
283
+ if (this.settings.allowedSourceIPs && this.settings.allowedSourceIPs.length > 0) {
284
+ // First add a default deny for all
285
+ const denyAllCommand = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} -j DROP -m comment --comment "${this.ruleTag}:DENY_ALL"`;
286
+
287
+ // Add allow rules for specific IPs
288
+ for (const ip of this.settings.allowedSourceIPs) {
289
+ const command = `${iptablesCmd} -t ${table} -A ${chain} -s ${ip} -p ${this.settings.protocol} -j ACCEPT -m comment --comment "${this.ruleTag}:ALLOWED"`;
290
+
291
+ // Check if rule already exists
292
+ if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
293
+ this.log('info', `Rule already exists, skipping: ${command}`);
294
+ continue;
295
+ }
296
+
297
+ await execAsync(command);
298
+ this.log('info', `Added allowed IP rule: ${command}`);
299
+
300
+ this.rules.push({
301
+ table,
302
+ chain,
303
+ command,
304
+ tag: `${this.ruleTag}:ALLOWED`,
305
+ added: true
306
+ });
307
+ }
308
+
309
+ // Now add the default deny after all allows
310
+ if (this.settings.checkExistingRules && await this.ruleExists(table, denyAllCommand, isIpv6)) {
311
+ this.log('info', `Rule already exists, skipping: ${denyAllCommand}`);
312
+ } else {
313
+ await execAsync(denyAllCommand);
314
+ this.log('info', `Added default deny rule: ${denyAllCommand}`);
315
+
316
+ this.rules.push({
317
+ table,
318
+ chain,
319
+ command: denyAllCommand,
320
+ tag: `${this.ruleTag}:DENY_ALL`,
321
+ added: true
322
+ });
323
+ }
324
+ }
325
+
326
+ return true;
327
+ } catch (err) {
328
+ this.log('error', `Failed to add source IP filter rules: ${err}`);
329
+ return false;
94
330
  }
95
331
  }
96
332
 
97
333
  /**
98
- * Removes the iptables rules that were added in start(), by matching the unique comment.
334
+ * Adds a port forwarding rule
99
335
  */
100
- public async stop(): Promise<void> {
101
- if (!this.rulesInstalled) return;
336
+ private async addPortForwardingRule(
337
+ fromPortRange: IPortRange,
338
+ toPortRange: IPortRange,
339
+ isIpv6: boolean = false
340
+ ): Promise<boolean> {
341
+ const iptablesCmd = this.getIptablesCommand(isIpv6);
342
+ const table = 'nat';
343
+ const chain = this.customChain || 'PREROUTING';
344
+
345
+ try {
346
+ // Handle single port case
347
+ if (fromPortRange.from === fromPortRange.to && toPortRange.from === toPortRange.to) {
348
+ // Single port forward
349
+ const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPortRange.from} ` +
350
+ `-j DNAT --to-destination ${this.settings.toHost}:${toPortRange.from} ` +
351
+ `-m comment --comment "${this.ruleTag}:DNAT"`;
352
+
353
+ // Check if rule already exists
354
+ if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
355
+ this.log('info', `Rule already exists, skipping: ${command}`);
356
+ } else {
357
+ await execAsync(command);
358
+ this.log('info', `Added port forwarding rule: ${command}`);
359
+
360
+ this.rules.push({
361
+ table,
362
+ chain,
363
+ command,
364
+ tag: `${this.ruleTag}:DNAT`,
365
+ added: true
366
+ });
367
+ }
368
+ } else if (fromPortRange.to - fromPortRange.from === toPortRange.to - toPortRange.from) {
369
+ // Port range forward with equal ranges
370
+ const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPortRange.from}:${fromPortRange.to} ` +
371
+ `-j DNAT --to-destination ${this.settings.toHost}:${toPortRange.from}-${toPortRange.to} ` +
372
+ `-m comment --comment "${this.ruleTag}:DNAT_RANGE"`;
373
+
374
+ // Check if rule already exists
375
+ if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
376
+ this.log('info', `Rule already exists, skipping: ${command}`);
377
+ } else {
378
+ await execAsync(command);
379
+ this.log('info', `Added port range forwarding rule: ${command}`);
380
+
381
+ this.rules.push({
382
+ table,
383
+ chain,
384
+ command,
385
+ tag: `${this.ruleTag}:DNAT_RANGE`,
386
+ added: true
387
+ });
388
+ }
389
+ } else {
390
+ // Unequal port ranges need individual rules
391
+ for (let i = 0; i <= fromPortRange.to - fromPortRange.from; i++) {
392
+ const fromPort = fromPortRange.from + i;
393
+ const toPort = toPortRange.from + i % (toPortRange.to - toPortRange.from + 1);
394
+
395
+ const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPort} ` +
396
+ `-j DNAT --to-destination ${this.settings.toHost}:${toPort} ` +
397
+ `-m comment --comment "${this.ruleTag}:DNAT_INDIVIDUAL"`;
398
+
399
+ // Check if rule already exists
400
+ if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
401
+ this.log('info', `Rule already exists, skipping: ${command}`);
402
+ continue;
403
+ }
404
+
405
+ await execAsync(command);
406
+ this.log('info', `Added individual port forwarding rule: ${command}`);
407
+
408
+ this.rules.push({
409
+ table,
410
+ chain,
411
+ command,
412
+ tag: `${this.ruleTag}:DNAT_INDIVIDUAL`,
413
+ added: true
414
+ });
415
+ }
416
+ }
417
+
418
+ // If preserveSourceIP is false, add a MASQUERADE rule
419
+ if (!this.settings.preserveSourceIP) {
420
+ // For port range
421
+ const masqCommand = `${iptablesCmd} -t nat -A POSTROUTING -p ${this.settings.protocol} -d ${this.settings.toHost} ` +
422
+ `--dport ${toPortRange.from}:${toPortRange.to} -j MASQUERADE ` +
423
+ `-m comment --comment "${this.ruleTag}:MASQ"`;
424
+
425
+ // Check if rule already exists
426
+ if (this.settings.checkExistingRules && await this.ruleExists('nat', masqCommand, isIpv6)) {
427
+ this.log('info', `Rule already exists, skipping: ${masqCommand}`);
428
+ } else {
429
+ await execAsync(masqCommand);
430
+ this.log('info', `Added MASQUERADE rule: ${masqCommand}`);
431
+
432
+ this.rules.push({
433
+ table: 'nat',
434
+ chain: 'POSTROUTING',
435
+ command: masqCommand,
436
+ tag: `${this.ruleTag}:MASQ`,
437
+ added: true
438
+ });
439
+ }
440
+ }
441
+
442
+ return true;
443
+ } catch (err) {
444
+ this.log('error', `Failed to add port forwarding rule: ${err}`);
445
+
446
+ // Try to roll back any rules that were already added
447
+ await this.rollbackRules();
448
+
449
+ return false;
450
+ }
451
+ }
102
452
 
103
- const dnatDelCmd = `iptables -t nat -D PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
104
- `-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
105
- `-m comment --comment "${this.ruleTag}:DNAT"`;
453
+ /**
454
+ * Special handling for NetworkProxy integration
455
+ */
456
+ private async setupNetworkProxyIntegration(isIpv6: boolean = false): Promise<boolean> {
457
+ if (!this.settings.netProxyIntegration?.enabled) {
458
+ return true;
459
+ }
460
+
461
+ const netProxyConfig = this.settings.netProxyIntegration;
462
+ const iptablesCmd = this.getIptablesCommand(isIpv6);
463
+ const table = 'nat';
464
+ const chain = this.customChain || 'PREROUTING';
465
+
106
466
  try {
107
- await execAsync(dnatDelCmd);
108
- console.log(`Removed iptables rule: ${dnatDelCmd}`);
467
+ // If redirectLocalhost is true, set up special rule to redirect localhost traffic to NetworkProxy
468
+ if (netProxyConfig.redirectLocalhost && netProxyConfig.sslTerminationPort) {
469
+ const redirectCommand = `${iptablesCmd} -t ${table} -A OUTPUT -p tcp -d 127.0.0.1 -j REDIRECT ` +
470
+ `--to-port ${netProxyConfig.sslTerminationPort} ` +
471
+ `-m comment --comment "${this.ruleTag}:NETPROXY_REDIRECT"`;
472
+
473
+ // Check if rule already exists
474
+ if (this.settings.checkExistingRules && await this.ruleExists(table, redirectCommand, isIpv6)) {
475
+ this.log('info', `Rule already exists, skipping: ${redirectCommand}`);
476
+ } else {
477
+ await execAsync(redirectCommand);
478
+ this.log('info', `Added NetworkProxy redirection rule: ${redirectCommand}`);
479
+
480
+ this.rules.push({
481
+ table,
482
+ chain: 'OUTPUT',
483
+ command: redirectCommand,
484
+ tag: `${this.ruleTag}:NETPROXY_REDIRECT`,
485
+ added: true
486
+ });
487
+ }
488
+ }
489
+
490
+ return true;
109
491
  } catch (err) {
110
- console.error(`Failed to remove iptables DNAT rule: ${err}`);
492
+ this.log('error', `Failed to set up NetworkProxy integration: ${err}`);
493
+ return false;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Rolls back rules that were added in case of error
499
+ */
500
+ private async rollbackRules(): Promise<void> {
501
+ // Process rules in reverse order (LIFO)
502
+ for (let i = this.rules.length - 1; i >= 0; i--) {
503
+ const rule = this.rules[i];
504
+
505
+ if (rule.added) {
506
+ try {
507
+ // Convert -A (add) to -D (delete)
508
+ const deleteCommand = rule.command.replace('-A', '-D');
509
+ await execAsync(deleteCommand);
510
+ this.log('info', `Rolled back rule: ${deleteCommand}`);
511
+
512
+ rule.added = false;
513
+ } catch (err) {
514
+ this.log('error', `Failed to roll back rule: ${err}`);
515
+ }
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Sets up iptables rules for port forwarding with enhanced features
522
+ */
523
+ public async start(): Promise<void> {
524
+ // Optionally clean the slate first
525
+ if (this.settings.forceCleanSlate) {
526
+ await IPTablesProxy.cleanSlate();
111
527
  }
528
+
529
+ // First set up any custom chains
530
+ if (this.settings.addJumpRule) {
531
+ const chainSetupSuccess = await this.setupCustomChain();
532
+ if (!chainSetupSuccess) {
533
+ throw new Error('Failed to set up custom chain');
534
+ }
535
+
536
+ // For IPv6 if enabled
537
+ if (this.settings.ipv6Support) {
538
+ const chainSetupSuccessIpv6 = await this.setupCustomChain(true);
539
+ if (!chainSetupSuccessIpv6) {
540
+ this.log('warn', 'Failed to set up IPv6 custom chain, continuing with IPv4 only');
541
+ }
542
+ }
543
+ }
544
+
545
+ // Add source IP filters
546
+ await this.addSourceIPFilter();
547
+ if (this.settings.ipv6Support) {
548
+ await this.addSourceIPFilter(true);
549
+ }
550
+
551
+ // Set up NetworkProxy integration if enabled
552
+ if (this.settings.netProxyIntegration?.enabled) {
553
+ const netProxySetupSuccess = await this.setupNetworkProxyIntegration();
554
+ if (!netProxySetupSuccess) {
555
+ this.log('warn', 'Failed to set up NetworkProxy integration');
556
+ }
557
+
558
+ if (this.settings.ipv6Support) {
559
+ await this.setupNetworkProxyIntegration(true);
560
+ }
561
+ }
562
+
563
+ // Normalize port specifications
564
+ const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
565
+ const toPortRanges = this.normalizePortSpec(this.settings.toPort);
566
+
567
+ // Handle the case where fromPort and toPort counts don't match
568
+ if (fromPortRanges.length !== toPortRanges.length) {
569
+ if (toPortRanges.length === 1) {
570
+ // If there's only one toPort, use it for all fromPorts
571
+ for (const fromRange of fromPortRanges) {
572
+ await this.addPortForwardingRule(fromRange, toPortRanges[0]);
573
+
574
+ if (this.settings.ipv6Support) {
575
+ await this.addPortForwardingRule(fromRange, toPortRanges[0], true);
576
+ }
577
+ }
578
+ } else {
579
+ throw new Error('Mismatched port counts: fromPort and toPort arrays must have equal length or toPort must be a single value');
580
+ }
581
+ } else {
582
+ // Add port forwarding rules for each port specification
583
+ for (let i = 0; i < fromPortRanges.length; i++) {
584
+ await this.addPortForwardingRule(fromPortRanges[i], toPortRanges[i]);
585
+
586
+ if (this.settings.ipv6Support) {
587
+ await this.addPortForwardingRule(fromPortRanges[i], toPortRanges[i], true);
588
+ }
589
+ }
590
+ }
591
+
592
+ // Final check - ensure we have at least one rule added
593
+ if (this.rules.filter(r => r.added).length === 0) {
594
+ throw new Error('No rules were added');
595
+ }
596
+ }
112
597
 
113
- if (!this.settings.preserveSourceIP) {
114
- const masqueradeDelCmd = `iptables -t nat -D POSTROUTING -p tcp -d ${this.settings.toHost} ` +
115
- `--dport ${this.settings.toPort} -j MASQUERADE ` +
116
- `-m comment --comment "${this.ruleTag}:MASQ"`;
598
+ /**
599
+ * Removes all added iptables rules
600
+ */
601
+ public async stop(): Promise<void> {
602
+ // Process rules in reverse order (LIFO)
603
+ for (let i = this.rules.length - 1; i >= 0; i--) {
604
+ const rule = this.rules[i];
605
+
606
+ if (rule.added) {
607
+ try {
608
+ // Convert -A (add) to -D (delete)
609
+ const deleteCommand = rule.command.replace('-A', '-D');
610
+ await execAsync(deleteCommand);
611
+ this.log('info', `Removed rule: ${deleteCommand}`);
612
+
613
+ rule.added = false;
614
+ } catch (err) {
615
+ this.log('error', `Failed to remove rule: ${err}`);
616
+ }
617
+ }
618
+ }
619
+
620
+ // If we created a custom chain, we need to clean it up
621
+ if (this.customChain) {
117
622
  try {
118
- await execAsync(masqueradeDelCmd);
119
- console.log(`Removed iptables rule: ${masqueradeDelCmd}`);
623
+ // First flush the chain
624
+ await execAsync(`iptables -t nat -F ${this.customChain}`);
625
+ this.log('info', `Flushed custom chain: ${this.customChain}`);
626
+
627
+ // Then delete it
628
+ await execAsync(`iptables -t nat -X ${this.customChain}`);
629
+ this.log('info', `Deleted custom chain: ${this.customChain}`);
630
+
631
+ // Same for IPv6 if enabled
632
+ if (this.settings.ipv6Support) {
633
+ try {
634
+ await execAsync(`ip6tables -t nat -F ${this.customChain}`);
635
+ await execAsync(`ip6tables -t nat -X ${this.customChain}`);
636
+ this.log('info', `Deleted IPv6 custom chain: ${this.customChain}`);
637
+ } catch (err) {
638
+ this.log('error', `Failed to delete IPv6 custom chain: ${err}`);
639
+ }
640
+ }
120
641
  } catch (err) {
121
- console.error(`Failed to remove iptables MASQUERADE rule: ${err}`);
642
+ this.log('error', `Failed to delete custom chain: ${err}`);
122
643
  }
123
644
  }
645
+
646
+ // Clear rules array
647
+ this.rules = [];
648
+ }
124
649
 
125
- this.rulesInstalled = false;
650
+ /**
651
+ * Synchronous version of stop, for use in exit handlers
652
+ */
653
+ public stopSync(): void {
654
+ // Process rules in reverse order (LIFO)
655
+ for (let i = this.rules.length - 1; i >= 0; i--) {
656
+ const rule = this.rules[i];
657
+
658
+ if (rule.added) {
659
+ try {
660
+ // Convert -A (add) to -D (delete)
661
+ const deleteCommand = rule.command.replace('-A', '-D');
662
+ execSync(deleteCommand);
663
+ this.log('info', `Removed rule: ${deleteCommand}`);
664
+
665
+ rule.added = false;
666
+ } catch (err) {
667
+ this.log('error', `Failed to remove rule: ${err}`);
668
+ }
669
+ }
670
+ }
671
+
672
+ // If we created a custom chain, we need to clean it up
673
+ if (this.customChain) {
674
+ try {
675
+ // First flush the chain
676
+ execSync(`iptables -t nat -F ${this.customChain}`);
677
+
678
+ // Then delete it
679
+ execSync(`iptables -t nat -X ${this.customChain}`);
680
+ this.log('info', `Deleted custom chain: ${this.customChain}`);
681
+
682
+ // Same for IPv6 if enabled
683
+ if (this.settings.ipv6Support) {
684
+ try {
685
+ execSync(`ip6tables -t nat -F ${this.customChain}`);
686
+ execSync(`ip6tables -t nat -X ${this.customChain}`);
687
+ } catch (err) {
688
+ // IPv6 failures are non-critical
689
+ }
690
+ }
691
+ } catch (err) {
692
+ this.log('error', `Failed to delete custom chain: ${err}`);
693
+ }
694
+ }
695
+
696
+ // Clear rules array
697
+ this.rules = [];
126
698
  }
127
699
 
128
700
  /**
@@ -130,26 +702,88 @@ export class IPTablesProxy {
130
702
  * It looks for rules with comments containing "IPTablesProxy:".
131
703
  */
132
704
  public static async cleanSlate(): Promise<void> {
705
+ await IPTablesProxy.cleanSlateInternal();
706
+
707
+ // Also clean IPv6 rules
708
+ await IPTablesProxy.cleanSlateInternal(true);
709
+ }
710
+
711
+ /**
712
+ * Internal implementation of cleanSlate with IPv6 support
713
+ */
714
+ private static async cleanSlateInternal(isIpv6: boolean = false): Promise<void> {
715
+ const iptablesCmd = isIpv6 ? 'ip6tables' : 'iptables';
716
+
133
717
  try {
134
- const { stdout } = await execAsync('iptables-save -t nat');
718
+ const { stdout } = await execAsync(`${iptablesCmd}-save -t nat`);
135
719
  const lines = stdout.split('\n');
136
720
  const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
721
+
722
+ // First, find and remove any custom chains
723
+ const customChains = new Set<string>();
724
+ const jumpRules: string[] = [];
725
+
137
726
  for (const line of proxyLines) {
727
+ if (line.includes('IPTablesProxy:JUMP')) {
728
+ // Extract chain name from jump rule
729
+ const match = line.match(/\s+-j\s+(\S+)\s+/);
730
+ if (match && match[1].startsWith('IPTablesProxy_')) {
731
+ customChains.add(match[1]);
732
+ jumpRules.push(line);
733
+ }
734
+ }
735
+ }
736
+
737
+ // Remove jump rules first
738
+ for (const line of jumpRules) {
138
739
  const trimmedLine = line.trim();
139
740
  if (trimmedLine.startsWith('-A')) {
140
- // Replace the "-A" with "-D" to form a deletion command.
741
+ // Replace the "-A" with "-D" to form a deletion command
141
742
  const deleteRule = trimmedLine.replace('-A', '-D');
142
- const cmd = `iptables -t nat ${deleteRule}`;
743
+ const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
143
744
  try {
144
745
  await execAsync(cmd);
145
- console.log(`Cleaned up iptables rule: ${cmd}`);
746
+ console.log(`Cleaned up iptables jump rule: ${cmd}`);
146
747
  } catch (err) {
147
- console.error(`Failed to remove iptables rule: ${cmd}`, err);
748
+ console.error(`Failed to remove iptables jump rule: ${cmd}`, err);
749
+ }
750
+ }
751
+ }
752
+
753
+ // Then remove all other rules
754
+ for (const line of proxyLines) {
755
+ if (!line.includes('IPTablesProxy:JUMP')) { // Skip jump rules we already handled
756
+ const trimmedLine = line.trim();
757
+ if (trimmedLine.startsWith('-A')) {
758
+ // Replace the "-A" with "-D" to form a deletion command
759
+ const deleteRule = trimmedLine.replace('-A', '-D');
760
+ const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
761
+ try {
762
+ await execAsync(cmd);
763
+ console.log(`Cleaned up iptables rule: ${cmd}`);
764
+ } catch (err) {
765
+ console.error(`Failed to remove iptables rule: ${cmd}`, err);
766
+ }
148
767
  }
149
768
  }
150
769
  }
770
+
771
+ // Finally clean up custom chains
772
+ for (const chain of customChains) {
773
+ try {
774
+ // Flush the chain
775
+ await execAsync(`${iptablesCmd} -t nat -F ${chain}`);
776
+ console.log(`Flushed custom chain: ${chain}`);
777
+
778
+ // Delete the chain
779
+ await execAsync(`${iptablesCmd} -t nat -X ${chain}`);
780
+ console.log(`Deleted custom chain: ${chain}`);
781
+ } catch (err) {
782
+ console.error(`Failed to delete custom chain ${chain}:`, err);
783
+ }
784
+ }
151
785
  } catch (err) {
152
- console.error(`Failed to run iptables-save: ${err}`);
786
+ console.error(`Failed to run ${iptablesCmd}-save: ${err}`);
153
787
  }
154
788
  }
155
789
 
@@ -159,25 +793,109 @@ export class IPTablesProxy {
159
793
  * This method is intended for use in process exit handlers.
160
794
  */
161
795
  public static cleanSlateSync(): void {
796
+ IPTablesProxy.cleanSlateSyncInternal();
797
+
798
+ // Also clean IPv6 rules
799
+ IPTablesProxy.cleanSlateSyncInternal(true);
800
+ }
801
+
802
+ /**
803
+ * Internal implementation of cleanSlateSync with IPv6 support
804
+ */
805
+ private static cleanSlateSyncInternal(isIpv6: boolean = false): void {
806
+ const iptablesCmd = isIpv6 ? 'ip6tables' : 'iptables';
807
+
162
808
  try {
163
- const stdout = execSync('iptables-save -t nat').toString();
809
+ const stdout = execSync(`${iptablesCmd}-save -t nat`).toString();
164
810
  const lines = stdout.split('\n');
165
811
  const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
812
+
813
+ // First, find and remove any custom chains
814
+ const customChains = new Set<string>();
815
+ const jumpRules: string[] = [];
816
+
166
817
  for (const line of proxyLines) {
818
+ if (line.includes('IPTablesProxy:JUMP')) {
819
+ // Extract chain name from jump rule
820
+ const match = line.match(/\s+-j\s+(\S+)\s+/);
821
+ if (match && match[1].startsWith('IPTablesProxy_')) {
822
+ customChains.add(match[1]);
823
+ jumpRules.push(line);
824
+ }
825
+ }
826
+ }
827
+
828
+ // Remove jump rules first
829
+ for (const line of jumpRules) {
167
830
  const trimmedLine = line.trim();
168
831
  if (trimmedLine.startsWith('-A')) {
832
+ // Replace the "-A" with "-D" to form a deletion command
169
833
  const deleteRule = trimmedLine.replace('-A', '-D');
170
- const cmd = `iptables -t nat ${deleteRule}`;
834
+ const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
171
835
  try {
172
836
  execSync(cmd);
173
- console.log(`Cleaned up iptables rule: ${cmd}`);
837
+ console.log(`Cleaned up iptables jump rule: ${cmd}`);
174
838
  } catch (err) {
175
- console.error(`Failed to remove iptables rule: ${cmd}`, err);
839
+ console.error(`Failed to remove iptables jump rule: ${cmd}`, err);
840
+ }
841
+ }
842
+ }
843
+
844
+ // Then remove all other rules
845
+ for (const line of proxyLines) {
846
+ if (!line.includes('IPTablesProxy:JUMP')) { // Skip jump rules we already handled
847
+ const trimmedLine = line.trim();
848
+ if (trimmedLine.startsWith('-A')) {
849
+ const deleteRule = trimmedLine.replace('-A', '-D');
850
+ const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
851
+ try {
852
+ execSync(cmd);
853
+ console.log(`Cleaned up iptables rule: ${cmd}`);
854
+ } catch (err) {
855
+ console.error(`Failed to remove iptables rule: ${cmd}`, err);
856
+ }
176
857
  }
177
858
  }
178
859
  }
860
+
861
+ // Finally clean up custom chains
862
+ for (const chain of customChains) {
863
+ try {
864
+ // Flush the chain
865
+ execSync(`${iptablesCmd} -t nat -F ${chain}`);
866
+
867
+ // Delete the chain
868
+ execSync(`${iptablesCmd} -t nat -X ${chain}`);
869
+ console.log(`Deleted custom chain: ${chain}`);
870
+ } catch (err) {
871
+ console.error(`Failed to delete custom chain ${chain}:`, err);
872
+ }
873
+ }
179
874
  } catch (err) {
180
- console.error(`Failed to run iptables-save: ${err}`);
875
+ console.error(`Failed to run ${iptablesCmd}-save: ${err}`);
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Logging utility that respects the enableLogging setting
881
+ */
882
+ private log(level: 'info' | 'warn' | 'error', message: string): void {
883
+ if (!this.settings.enableLogging && level === 'info') {
884
+ return;
885
+ }
886
+
887
+ const timestamp = new Date().toISOString();
888
+
889
+ switch (level) {
890
+ case 'info':
891
+ console.log(`[${timestamp}] [INFO] ${message}`);
892
+ break;
893
+ case 'warn':
894
+ console.warn(`[${timestamp}] [WARN] ${message}`);
895
+ break;
896
+ case 'error':
897
+ console.error(`[${timestamp}] [ERROR] ${message}`);
898
+ break;
181
899
  }
182
900
  }
183
901
  }