-
Notifications
You must be signed in to change notification settings - Fork 323
Expand file tree
/
Copy pathgitlab-client-pool.ts
More file actions
258 lines (228 loc) · 8.96 KB
/
Copy pathgitlab-client-pool.ts
File metadata and controls
258 lines (228 loc) · 8.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import { Agent } from "http";
import { Agent as HttpsAgent } from "https";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
import { SocksProxyAgent } from "socks-proxy-agent";
import fs from "fs";
/**
* Checks if a URL should bypass the proxy based on NO_PROXY patterns.
* Supports:
* - Exact hostname matches (e.g., "localhost", "gitlab.example.com")
* - Domain suffix matches (e.g., ".example.com" matches "gitlab.example.com")
* - IP addresses (e.g., "127.0.0.1", "192.168.1.1")
* - Wildcard "*" to bypass all proxies
* - Port-specific matches (e.g., "example.com:8080")
*
* @param url The URL to check
* @param noProxy Comma-separated list of patterns from NO_PROXY
* @returns true if the URL should bypass the proxy, false otherwise
*/
function shouldBypassProxy(url: string, noProxy: string | undefined): boolean {
if (!noProxy) {
return false;
}
// Parse URL to get hostname and port
let hostname: string;
let port: string;
let protocol: string;
try {
const parsedUrl = new URL(url);
hostname = parsedUrl.hostname.toLowerCase();
protocol = parsedUrl.protocol;
// Use explicit port if provided, otherwise use default port based on protocol
port = parsedUrl.port || (protocol === 'https:' ? '443' : '80');
} catch {
return false;
}
// Split NO_PROXY into patterns and trim whitespace
const patterns = noProxy.split(',').map(p => p.trim().toLowerCase()).filter(p => p.length > 0);
for (const pattern of patterns) {
// Wildcard matches everything
if (pattern === '*') {
return true;
}
// Handle port-specific patterns (e.g., "example.com:8080")
const [patternHost, patternPort] = pattern.split(':');
// If pattern specifies a port, check if it matches
if (patternPort && port !== patternPort) {
continue;
}
// Check for domain suffix match (e.g., ".example.com")
if (patternHost.startsWith('.')) {
const suffix = patternHost.substring(1);
if (hostname === suffix || hostname.endsWith('.' + suffix)) {
return true;
}
}
// Check for exact hostname match
else if (hostname === patternHost) {
return true;
}
}
return false;
}
export interface GitLabClientPoolOptions {
apiUrls?: string[];
httpProxy?: string;
httpsProxy?: string;
noProxy?: string;
rejectUnauthorized?: boolean;
caCertPath?: string;
poolMaxSize?: number;
}
export interface ClientAgents {
httpAgent: Agent;
httpsAgent: HttpsAgent;
}
/**
* Manages a pool of HTTP/HTTPS agents for different GitLab API URLs.
* This allows the server to efficiently handle requests to multiple GitLab instances
* by reusing agents and their underlying TCP connections.
*/
export class GitLabClientPool {
private clients: Map<string, ClientAgents> = new Map();
private options: GitLabClientPoolOptions;
constructor(options: GitLabClientPoolOptions) {
this.options = options;
// Initialization is now done on-demand
}
/**
* Creates a pair of HTTP and HTTPS agents for a specific API URL,
* considering proxy and SSL/TLS settings.
* @param apiUrl The base URL for which to create the agents.
* @returns A `ClientAgents` object containing the configured agents.
*/
private createAgentsForUrl(apiUrl: string): ClientAgents {
const { httpProxy, httpsProxy, noProxy, rejectUnauthorized, caCertPath } = this.options;
let sslOptions: { rejectUnauthorized?: boolean; ca?: Buffer } = {};
if (rejectUnauthorized === false) {
sslOptions.rejectUnauthorized = false;
} else if (caCertPath) {
try {
sslOptions.ca = fs.readFileSync(caCertPath);
} catch (error) {
console.error(`Failed to read CA certificate from ${caCertPath}:`, error);
throw new Error(`Failed to read CA certificate: ${caCertPath}`);
}
}
// Check if this URL should bypass the proxy
const bypassProxy = shouldBypassProxy(apiUrl, noProxy);
let httpAgent: Agent;
let httpsAgent: HttpsAgent;
// Configure HTTP agent with proxy if specified and not bypassed
if (httpProxy && !bypassProxy) {
httpAgent = httpProxy.startsWith("socks")
? new SocksProxyAgent(httpProxy)
: new HttpProxyAgent(httpProxy);
} else {
httpAgent = new Agent({ keepAlive: true });
}
// Configure HTTPS agent with proxy and SSL options if specified and not bypassed
if (httpsProxy && !bypassProxy) {
httpsAgent = httpsProxy.startsWith("socks")
// The `as any` cast is used here to bypass a TypeScript type mismatch error.
// The `socks-proxy-agent` documentation indicates that TLS options like
// `rejectUnauthorized` and `ca` are valid in the constructor's options
// object, but the type definitions in this environment seem to disagree.
// This cast ensures the options are passed through at runtime.
? new SocksProxyAgent(httpsProxy, sslOptions as any)
: new HttpsProxyAgent(httpsProxy, { ...sslOptions });
} else {
httpsAgent = new HttpsAgent({ ...sslOptions, keepAlive: true });
}
return { httpAgent, httpsAgent };
}
/**
* Retrieves the appropriate agent (HTTP or HTTPS) for a given API URL.
* If an agent for the URL does not exist, it creates and caches one.
* @param apiUrl The full URL of the request.
* @returns The corresponding `Agent` for the URL's protocol.
*/
public getOrCreateAgentForUrl(apiUrl: string): Agent {
const agents = this.getOrCreateAgentsForUrl(apiUrl);
const url = new URL(apiUrl);
return url.protocol === "https:" ? agents.httpsAgent : agents.httpAgent;
}
/**
* Returns an agent-selection function for use with node-fetch's `agent` option.
* The returned function picks the correct HTTP or HTTPS agent based on the
* request URL's protocol. This is critical for self-hosted GitLab instances
* where the server may redirect between HTTP and HTTPS (e.g., when
* `external_url` differs from the actual internal protocol).
* @param apiUrl The base API URL used to look up or create the agent pair.
* @returns A function `(parsedURL: URL) => Agent` suitable for node-fetch.
*/
public getAgentFunctionForUrl(apiUrl: string): (parsedURL: URL) => Agent {
const agents = this.getOrCreateAgentsForUrl(apiUrl);
return (parsedURL: URL) => {
return parsedURL.protocol === "https:" ? agents.httpsAgent : agents.httpAgent;
};
}
/**
* Ensures agents exist for the given API URL and returns the pair.
* @param apiUrl The full URL of the request.
* @returns The `ClientAgents` (both HTTP and HTTPS agents) for the URL.
*/
private getOrCreateAgentsForUrl(apiUrl: string): ClientAgents {
const url = new URL(apiUrl);
const baseUrl = `${url.protocol}//${url.host}${url.pathname.substring(0, url.pathname.lastIndexOf('/api/v4') + '/api/v4'.length)}`;
if (!this.clients.has(baseUrl)) {
// Check pool size limit
if (this.options.poolMaxSize !== undefined && this.clients.size >= this.options.poolMaxSize) {
throw new Error(`Server capacity reached: Connection pool is full (max ${this.options.poolMaxSize} instances). Please try again later.`);
}
this.clients.set(baseUrl, this.createAgentsForUrl(baseUrl));
}
const agents = this.clients.get(baseUrl);
if (!agents) {
// This should not happen given the logic above, but it satisfies TypeScript
throw new Error(`Failed to create or get client for URL: ${baseUrl}`);
}
return agents;
}
/**
* Retrieves the client agents for a specific base API URL.
* @param apiUrl The base API URL (e.g., "https://gitlab.com/api/v4").
* @returns The `ClientAgents` object or undefined if not found.
*/
public getClient(apiUrl: string): ClientAgents | undefined {
return this.clients.get(apiUrl);
}
/**
* Returns the default client agents, which corresponds to the first URL in the list.
* @returns The default `ClientAgents`.
*/
public getDefaultClient(): ClientAgents {
const defaultUrl = this.options.apiUrls?.[0];
if (!defaultUrl) {
throw new Error("No default API URL configured.");
}
if (!this.clients.has(defaultUrl)) {
this.clients.set(defaultUrl, this.createAgentsForUrl(defaultUrl));
}
return this.clients.get(defaultUrl)!;
}
/**
* Destroy all pooled agents and clear pool state.
* This should be called on graceful shutdown so sockets are closed
* and the process can exit cleanly.
*/
public closeAll(): void {
for (const [, agents] of this.clients) {
const destroyIfSupported = (agent: unknown) => {
if (agent && typeof (agent as { destroy?: () => void }).destroy === "function") {
(agent as { destroy: () => void }).destroy();
}
};
destroyIfSupported(agents.httpAgent);
destroyIfSupported(agents.httpsAgent);
}
this.clients.clear();
}
public getStats(): { size: number; maxSize: number } {
return {
size: this.clients.size,
maxSize: this.options.poolMaxSize ?? 0,
};
}
}