Skip to content

Commit 0fb3888

Browse files
committed
feat: allow marking proxy server as IPv6 only
This is a bit hacky and kind of a quick-fix. The more proper way would be to detect (or allow marking) whether proxies have IPv4 and/or IPv6 by contacting an external service that 'checks' for that.
1 parent 20060e4 commit 0fb3888

7 files changed

Lines changed: 125 additions & 31 deletions

File tree

src/http/clients/HttpClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default abstract class HttpClient {
2727

2828
protected abstract request(url: string, options: FullRequestOptions): Promise<HttpResponse>;
2929

30-
protected abstract selectDispatcher(): Undici.Dispatcher;
30+
protected abstract selectDispatcher(skipIpv6Only?: boolean): Undici.Dispatcher;
3131

3232
protected getDefaultAgentOptions(): Undici.Agent.Options {
3333
return {

src/http/clients/ProxyPoolHttpClient.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Net from 'node:net';
12
import { SocksClientError } from 'socks';
23
import { container, singleton } from 'tsyringe';
34
import * as Undici from 'undici';
@@ -9,6 +10,7 @@ import RoundRobinProxyPool from '../../net/proxy/RoundRobinProxyPool.js';
910
import ProxyPoolHttpClientHealthcheckTask from '../../task_queue/tasks/ProxyPoolHttpClientHealthcheckTask.js';
1011
import SentrySdk from '../../util/SentrySdk.js';
1112
import ResolvedToNonUnicastIpError from '../dns/errors/ResolvedToNonUnicastIpError.js';
13+
import CachedDnsResolver from '../dns/resolver/CachedDnsResolver.js';
1214
import HttpResponse from '../HttpResponse.js';
1315
import { FullRequestOptions } from './HttpClient.js';
1416
import SimpleHttpClient from './SimpleHttpClient.js';
@@ -32,6 +34,7 @@ export default class ProxyPoolHttpClient extends SimpleHttpClient {
3234
constructor(
3335
proxyServerConfigurationProvider: ProxyServerConfigurationProvider,
3436
socksProxyAgentFactory: SocksProxyAgentFactory,
37+
private readonly dnsResolver: CachedDnsResolver,
3538
) {
3639
super();
3740

@@ -50,7 +53,9 @@ export default class ProxyPoolHttpClient extends SimpleHttpClient {
5053
protected async request(url: string, options: FullRequestOptions, triesLeft = this.retriesOnProxyError): Promise<HttpResponse> {
5154
this.ensureUrlLooksLikePublicServer(url);
5255

53-
const proxy = this.selectNextProxy();
56+
const skipIpv6Only = await this.determineSkipIpv6OnlyProxies(url);
57+
58+
const proxy = this.selectNextProxy(skipIpv6Only);
5459
let response: Undici.Dispatcher.ResponseData;
5560

5661
if (SimpleHttpClient.DEBUG_LOGGING) {
@@ -94,23 +99,45 @@ export default class ProxyPoolHttpClient extends SimpleHttpClient {
9499
return httpResponse;
95100
}
96101

97-
protected selectDispatcher(): Undici.Dispatcher {
98-
const proxy = this.proxyPool.selectNextProxy();
102+
protected selectDispatcher(skipIpv6Only = false): Undici.Dispatcher {
103+
const proxy = this.proxyPool.selectNextProxy(skipIpv6Only);
99104
return proxy.undiciDispatcher;
100105
}
101106

102-
private selectNextProxy(): UndiciProxyServer {
103-
const firstProxySelected = this.proxyPool.selectNextProxy();
107+
private selectNextProxy(skipIpv6Only: boolean): UndiciProxyServer {
108+
const firstProxySelected = this.proxyPool.selectNextProxy(skipIpv6Only);
104109
let proxy = firstProxySelected;
105110
while (proxy.health.unhealthy) {
106-
proxy = this.proxyPool.selectNextProxy();
111+
proxy = this.proxyPool.selectNextProxy(skipIpv6Only);
107112
if (proxy === firstProxySelected) {
108113
throw new Error('All configured proxies are unhealthy');
109114
}
110115
}
111116
return proxy;
112117
}
113118

119+
private async determineSkipIpv6OnlyProxies(url: string): Promise<boolean> {
120+
const hostname = new URL(url).hostname;
121+
const hostnameIpVersion = Net.isIP(hostname);
122+
if (hostnameIpVersion !== 0) {
123+
return hostnameIpVersion !== 6;
124+
}
125+
126+
const lookupResult = await this.dnsResolver.lookupAsync(hostname, { all: true });
127+
128+
if (typeof lookupResult.address === 'string') {
129+
return !Net.isIPv6(lookupResult.address);
130+
}
131+
132+
for (const address of lookupResult.address) {
133+
if (address.family === 6) {
134+
return false;
135+
}
136+
}
137+
138+
return true;
139+
}
140+
114141
private isSocketError(err: unknown): boolean {
115142
if (err instanceof SocksClientError) {
116143
return err.message.includes('ECONNREFUSED');

src/minecraft/server/ping/AbstractMinecraftServerPing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export default abstract class AbstractMinecraftServerPing {
9797
}
9898

9999
if (this.socksProxyPool.proxyCount > 0) {
100-
const proxy = this.socksProxyPool.selectNextProxy();
100+
const proxy = this.socksProxyPool.selectNextProxy(Net.isIPv4(ip));
101101
const socket = await this.socksProxyConnector.createConnection(proxy, ip, port);
102102
return socket.setNoDelay();
103103
}

src/net/proxy/ProxyServerConfigurationProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type ProxyServer = {
1313

1414
username: string,
1515
password: string,
16+
ipv6Only: boolean,
1617

1718
socksProxyOptions?: SocksProxyOptions
1819
};
@@ -74,6 +75,7 @@ export default class ProxyServerConfigurationProvider {
7475
displayName: parsedUri.searchParams.get('name')?.trim() || simplifiedUri,
7576
username: decodeURIComponent(parsedUri.username),
7677
password: decodeURIComponent(parsedUri.password),
78+
ipv6Only: parsedUri.searchParams.get('ipv6only') === '1',
7779
socksProxyOptions,
7880
});
7981
}

src/net/proxy/RoundRobinProxyPool.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,20 @@ export default class RoundRobinProxyPool<T extends ProxyServer> {
1616
return this.proxies;
1717
}
1818

19-
selectNextProxy(): T {
19+
selectNextProxy(skipIpv6Only: boolean): T {
2020
if (this.proxies.length === 0) {
2121
throw new Error('No proxies available');
2222
}
2323

24-
const proxy = this.proxies[this.nextProxyIndex];
25-
this.nextProxyIndex = (this.nextProxyIndex + 1) % this.proxies.length;
26-
return proxy;
24+
let startIndex = this.nextProxyIndex;
25+
do {
26+
const proxy = this.proxies[this.nextProxyIndex];
27+
this.nextProxyIndex = (this.nextProxyIndex + 1) % this.proxies.length;
28+
if (!(skipIpv6Only && proxy.ipv6Only)) {
29+
return proxy;
30+
}
31+
} while (skipIpv6Only && startIndex !== this.nextProxyIndex);
32+
33+
throw new Error(`No suitable proxy found (skipIpv6Only=${skipIpv6Only})`);
2734
}
2835
}

tests/unit/net/proxy/ProxyServerConfigurationProvider.test.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { jest } from '@jest/globals';
2-
import ProxyServerConfigurationProvider from '../../../../src/net/proxy/ProxyServerConfigurationProvider.js';
2+
import ProxyServerConfigurationProvider, {
3+
ProxyServer,
4+
SocksProxyServer,
5+
} from '../../../../src/net/proxy/ProxyServerConfigurationProvider.js';
36
import SentrySdk from '../../../../src/util/SentrySdk.js';
47

58
describe('ProxyServerConfigurationProvider', () => {
@@ -19,8 +22,24 @@ describe('ProxyServerConfigurationProvider', () => {
1922
displayName: 'test',
2023
username: '',
2124
password: '',
25+
ipv6Only: false,
2226
},
23-
]);
27+
] satisfies ProxyServer[]);
28+
expect(configProvider.getSocksProxyServers()).toEqual([]);
29+
});
30+
31+
test('Provide for proxy with ipv6only', () => {
32+
const configProvider = new ProxyServerConfigurationProvider(['https://proxy.example.com/?name=test&ipv6only=1']);
33+
34+
expect(configProvider.getProxyServers()).toEqual([
35+
{
36+
simplifiedUri: 'https://proxy.example.com/',
37+
displayName: 'test',
38+
username: '',
39+
password: '',
40+
ipv6Only: true,
41+
},
42+
] satisfies ProxyServer[]);
2443
expect(configProvider.getSocksProxyServers()).toEqual([]);
2544
});
2645

@@ -33,20 +52,22 @@ describe('ProxyServerConfigurationProvider', () => {
3352
displayName: `${protocol}://proxy.example.com/`,
3453
username: '',
3554
password: '',
55+
ipv6Only: false,
3656
},
37-
]);
57+
] satisfies ProxyServer[]);
3858
expect(configProvider.getSocksProxyServers()).toEqual([]);
3959
});
4060

41-
test.each([5, 4])('Provide for one socks%d proxy server', (socksVersion: number) => {
61+
test.each([5, 4] satisfies (4 | 5)[])('Provide for one socks%d proxy server', (socksVersion: 4 | 5) => {
4262
const configProvider = new ProxyServerConfigurationProvider([`socks${socksVersion}://user:pass@proxy.example.com:8899`]);
4363

44-
const expectedProxies = [
64+
const expectedProxies: SocksProxyServer[] = [
4565
{
4666
simplifiedUri: `socks${socksVersion}://proxy.example.com:8899/`,
4767
displayName: `socks${socksVersion}://proxy.example.com:8899/`,
4868
username: 'user',
4969
password: 'pass',
70+
ipv6Only: false,
5071
socksProxyOptions: {
5172
version: socksVersion,
5273
host: 'proxy.example.com',
@@ -67,26 +88,29 @@ describe('ProxyServerConfigurationProvider', () => {
6788
'socks4://user:pass@proxy2.example.com:8899',
6889
]);
6990

70-
const expectedHttpProxies = [
91+
const expectedHttpProxies: ProxyServer[] = [
7192
{
7293
simplifiedUri: 'http://proxy0.example.com:8080/',
7394
displayName: 'http://proxy0.example.com:8080/',
7495
username: '',
7596
password: '',
97+
ipv6Only: false,
7698
},
7799
{
78100
simplifiedUri: 'https://proxy1.example.com/',
79101
displayName: 'https://proxy1.example.com/',
80102
username: '',
81103
password: '',
104+
ipv6Only: false,
82105
},
83106
];
84-
const expectedSocksProxies = [
107+
const expectedSocksProxies: SocksProxyServer[] = [
85108
{
86109
simplifiedUri: 'socks5://[::1]:1234/',
87110
displayName: 'socks5://[::1]:1234/',
88111
username: '',
89112
password: '',
113+
ipv6Only: false,
90114
socksProxyOptions: {
91115
version: 5,
92116
host: '::1',
@@ -99,6 +123,7 @@ describe('ProxyServerConfigurationProvider', () => {
99123
displayName: 'socks4://proxy2.example.com:8899/',
100124
username: 'user',
101125
password: 'pass',
126+
ipv6Only: false,
102127
socksProxyOptions: {
103128
version: 4,
104129
host: 'proxy2.example.com',
@@ -155,8 +180,10 @@ describe('ProxyServerConfigurationProvider', () => {
155180
expect(configProvider.getProxyServers().length).toBe(4);
156181

157182
expect(SentrySdk.logAndCaptureWarning).toHaveBeenCalledTimes(2);
158-
expect(SentrySdk.logAndCaptureWarning).toHaveBeenNthCalledWith(1, `Proxy server 'https://proxy1.example.com/' is configured multiple times`);
159-
expect(SentrySdk.logAndCaptureWarning).toHaveBeenNthCalledWith(2, `Proxy server 'https://proxy1.example.com/' is configured multiple times`);
183+
expect(SentrySdk.logAndCaptureWarning)
184+
.toHaveBeenNthCalledWith(1, `Proxy server 'https://proxy1.example.com/' is configured multiple times`);
185+
expect(SentrySdk.logAndCaptureWarning)
186+
.toHaveBeenNthCalledWith(2, `Proxy server 'https://proxy1.example.com/' is configured multiple times`);
160187
});
161188

162189
test('Warning is logged for duplicate proxy server names', () => {
@@ -171,7 +198,9 @@ describe('ProxyServerConfigurationProvider', () => {
171198
expect(configProvider.getProxyServers().length).toBe(4);
172199

173200
expect(SentrySdk.logAndCaptureWarning).toHaveBeenCalledTimes(2);
174-
expect(SentrySdk.logAndCaptureWarning).toHaveBeenNthCalledWith(1, `Proxy server name 'test' is configured multiple times`);
175-
expect(SentrySdk.logAndCaptureWarning).toHaveBeenNthCalledWith(2, `Proxy server name 'test' is configured multiple times`);
201+
expect(SentrySdk.logAndCaptureWarning)
202+
.toHaveBeenNthCalledWith(1, `Proxy server name 'test' is configured multiple times`);
203+
expect(SentrySdk.logAndCaptureWarning)
204+
.toHaveBeenNthCalledWith(2, `Proxy server name 'test' is configured multiple times`);
176205
});
177206
});

tests/unit/net/proxy/RoundRobinProxyPool.test.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,60 @@ describe('RoundRobinProxyPool', () => {
66
const proxyPool = new RoundRobinProxyPool([]);
77

88
expect(proxyPool.proxyCount).toBe(0);
9-
expect(() => proxyPool.selectNextProxy()).toThrow('No proxies available');
9+
expect(() => proxyPool.selectNextProxy(false)).toThrow('No proxies available');
1010
});
1111

12-
test('Calling #selectNextProxy() always returns the same proxy', () => {
12+
test('Calling #selectNextProxy(skipIpv6Only=false) always returns the next proxy and repeats from the start', () => {
1313
const proxyServers = [
1414
createProxyServerConfig('proxy1'),
15-
createProxyServerConfig('proxy2'),
15+
createProxyServerConfig('proxy2', true),
1616
createProxyServerConfig('proxy3'),
1717
];
1818
const proxyPool = new RoundRobinProxyPool(proxyServers);
1919

2020
expect(proxyPool.proxyCount).toBe(3);
2121

22-
expect(proxyPool.selectNextProxy()).toBe(proxyServers[0]);
23-
expect(proxyPool.selectNextProxy()).toBe(proxyServers[1]);
24-
expect(proxyPool.selectNextProxy()).toBe(proxyServers[2]);
25-
expect(proxyPool.selectNextProxy()).toBe(proxyServers[0]);
22+
expect(proxyPool.selectNextProxy(false)).toBe(proxyServers[0]);
23+
expect(proxyPool.selectNextProxy(false)).toBe(proxyServers[1]);
24+
expect(proxyPool.selectNextProxy(false)).toBe(proxyServers[2]);
25+
expect(proxyPool.selectNextProxy(false)).toBe(proxyServers[0]);
26+
});
27+
28+
test('Calling #selectNextProxy(skipIpv6Only=true) always returns the next proxy and repeats from the start', () => {
29+
const proxyServers = [
30+
createProxyServerConfig('proxy1'),
31+
createProxyServerConfig('proxy2', true),
32+
createProxyServerConfig('proxy3'),
33+
];
34+
const proxyPool = new RoundRobinProxyPool(proxyServers);
35+
36+
expect(proxyPool.proxyCount).toBe(3);
37+
38+
expect(proxyPool.selectNextProxy(true)).toBe(proxyServers[0]);
39+
expect(proxyPool.selectNextProxy(true)).toBe(proxyServers[2]);
40+
expect(proxyPool.selectNextProxy(true)).toBe(proxyServers[0]);
41+
expect(proxyPool.selectNextProxy(true)).toBe(proxyServers[2]);
42+
});
43+
44+
test('Calling #selectNextProxy(skipIpv6Only=true) throws Error if only ipv6 proxies exist', () => {
45+
const proxyServers = [
46+
createProxyServerConfig('proxy1', true),
47+
createProxyServerConfig('proxy2', true),
48+
createProxyServerConfig('proxy3', true),
49+
];
50+
const proxyPool = new RoundRobinProxyPool(proxyServers);
51+
52+
expect(proxyPool.proxyCount).toBe(3);
53+
expect(() => proxyPool.selectNextProxy(true)).toThrow(`No suitable proxy found (skipIpv6Only=true)`);
2654
});
2755
});
2856

29-
function createProxyServerConfig(name: string): ProxyServer {
57+
function createProxyServerConfig(name: string, ipv6Only = false): ProxyServer {
3058
return {
3159
displayName: name,
3260
simplifiedUri: 'socks5://' + name,
3361
username: '',
3462
password: '',
63+
ipv6Only,
3564
};
3665
}

0 commit comments

Comments
 (0)