@aikidosec/broker-client 1.0.2 → 1.0.4
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/app/client.js +39 -35
- package/app/resourceManager.js +75 -2
- package/package.json +2 -1
package/app/client.js
CHANGED
|
@@ -12,6 +12,7 @@ import { Address4, Address6 } from 'ip-address';
|
|
|
12
12
|
import dns from 'native-dns';
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import { ResourceManager } from './resourceManager.js';
|
|
15
|
+
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
15
16
|
|
|
16
17
|
// Configure logging
|
|
17
18
|
const log = {
|
|
@@ -83,7 +84,6 @@ async function resolveInternalHostname(hostname) {
|
|
|
83
84
|
const dnsPromises = await import('dns/promises');
|
|
84
85
|
const result = await dnsPromises.resolve4(hostname);
|
|
85
86
|
const ip = result[0];
|
|
86
|
-
log.debug(`Resolved ${hostname} to ${ip} (system DNS)`);
|
|
87
87
|
return ip;
|
|
88
88
|
} catch (e) {
|
|
89
89
|
log.error(`Failed to resolve ${hostname}: ${e.message}`);
|
|
@@ -162,13 +162,11 @@ function isInternalUrl(url) {
|
|
|
162
162
|
// Not an IP, allow it through if we have custom DNS configured
|
|
163
163
|
// The actual resolution will happen in the forward_request handler
|
|
164
164
|
if (DNS_SERVERS !== null && DNS_SERVERS.length > 0) {
|
|
165
|
-
log.debug(`Hostname ${hostname} will be resolved with custom DNS during forward`);
|
|
166
165
|
return true;
|
|
167
166
|
}
|
|
168
167
|
|
|
169
168
|
// For system DNS, we can't do synchronous lookup here
|
|
170
169
|
// Allow all private hostnames through and validate during forward
|
|
171
|
-
log.debug(`Hostname ${hostname} will be validated during forward`);
|
|
172
170
|
return true;
|
|
173
171
|
}
|
|
174
172
|
}
|
|
@@ -216,36 +214,9 @@ function isInternalUrl(url) {
|
|
|
216
214
|
}
|
|
217
215
|
}
|
|
218
216
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
function isResourceAllowed(resource) {
|
|
223
|
-
const registeredResources = resourceManager.getResources();
|
|
224
|
-
|
|
225
|
-
// If no resources registered yet, deny (customer must register resources first)
|
|
226
|
-
if (Object.keys(registeredResources).length === 0) {
|
|
227
|
-
log.warn("No resources registered - customer must register resources via setup mode");
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Normalize the target URL for comparison
|
|
232
|
-
const resourceParsed = new URL(resource);
|
|
233
|
-
const resourceNormalized = `${resourceParsed.protocol}//${resourceParsed.host}${resourceParsed.pathname || '/'}`;
|
|
234
|
-
|
|
235
|
-
// Check if the exact base_url is registered
|
|
236
|
-
for (const baseUrl of Object.keys(registeredResources)) {
|
|
237
|
-
const baseParsed = new URL(baseUrl);
|
|
238
|
-
const baseNormalized = `${baseParsed.protocol}//${baseParsed.host}${baseParsed.pathname || '/'}`;
|
|
239
|
-
|
|
240
|
-
// Allow if target URL starts with registered base URL
|
|
241
|
-
if (resourceNormalized.startsWith(baseNormalized)) {
|
|
242
|
-
log.info(`Resource ${resource} matches registered base_url ${baseUrl}`);
|
|
243
|
-
return true;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
log.warn(`Resource ${resource} not found in registered resources: ${Object.keys(registeredResources)}`);
|
|
248
|
-
return false;
|
|
217
|
+
let agent;
|
|
218
|
+
if (process.env.HTTPS_PROXY || process.env.ALL_PROXY) {
|
|
219
|
+
agent = new HttpsProxyAgent(process.env.HTTPS_PROXY || process.env.ALL_PROXY);
|
|
249
220
|
}
|
|
250
221
|
|
|
251
222
|
// Create Socket.IO client
|
|
@@ -253,12 +224,14 @@ const socket = io(SERVER_URL, {
|
|
|
253
224
|
auth: {
|
|
254
225
|
client_secret: CLIENT_SECRET
|
|
255
226
|
},
|
|
227
|
+
agent,
|
|
256
228
|
transports: ['websocket', 'polling'],
|
|
257
229
|
reconnection: true,
|
|
258
230
|
reconnectionAttempts: Infinity,
|
|
259
231
|
reconnectionDelay: 1000,
|
|
260
232
|
reconnectionDelayMax: 30000,
|
|
261
233
|
randomizationFactor: 0.5,
|
|
234
|
+
tryAllTransports: true, // if we don't, it won't try to fallback from websocket to polling
|
|
262
235
|
autoConnect: false // Don't connect until after registration
|
|
263
236
|
});
|
|
264
237
|
|
|
@@ -283,6 +256,30 @@ socket.on('disconnect', () => {
|
|
|
283
256
|
log.warn("Disconnected from broker server");
|
|
284
257
|
});
|
|
285
258
|
|
|
259
|
+
socket.on('connect_error', (error) => {
|
|
260
|
+
log.error(`Socket.IO connection error: ${error.message}`);
|
|
261
|
+
if (error.description) {
|
|
262
|
+
log.error(` Description: ${JSON.stringify(error.description)}`);
|
|
263
|
+
}
|
|
264
|
+
if (error.context) {
|
|
265
|
+
log.error(` Context: ${JSON.stringify(error.context)}`);
|
|
266
|
+
}
|
|
267
|
+
log.error(` Type: ${error.type || 'unknown'}`);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Manager events (on socket.io)
|
|
271
|
+
socket.io.on('error', (error) => {
|
|
272
|
+
log.error(`Socket.IO Manager error: ${error.message || JSON.stringify(error)}`);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
socket.io.on('reconnect_error', (error) => {
|
|
276
|
+
log.error(`Socket.IO reconnection error: ${error.message}`);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
socket.io.on('reconnect_failed', () => {
|
|
280
|
+
log.error(`Socket.IO reconnection failed after all attempts`);
|
|
281
|
+
});
|
|
282
|
+
|
|
286
283
|
socket.on('forward_request', async (data, callback) => {
|
|
287
284
|
/**
|
|
288
285
|
* Receive request from broker server and forward to internal resource
|
|
@@ -308,7 +305,7 @@ socket.on('forward_request', async (data, callback) => {
|
|
|
308
305
|
return;
|
|
309
306
|
}
|
|
310
307
|
|
|
311
|
-
if (!
|
|
308
|
+
if (!(await resourceManager.isResourceAllowedWithSync(targetUrl))) {
|
|
312
309
|
callback({
|
|
313
310
|
request_id: requestId,
|
|
314
311
|
status_code: 403,
|
|
@@ -421,7 +418,14 @@ async function registerWithServer() {
|
|
|
421
418
|
log.warn(`Registration attempt ${attempt + 1} failed: ${response.status} - ${response.data}`);
|
|
422
419
|
}
|
|
423
420
|
} catch (error) {
|
|
424
|
-
|
|
421
|
+
if (error.response) {
|
|
422
|
+
// HTTP error response
|
|
423
|
+
log.warn(`Registration attempt ${attempt + 1} failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
|
424
|
+
} else {
|
|
425
|
+
// Error setting up request
|
|
426
|
+
log.warn(`Registration attempt ${attempt + 1} failed: ${error.message}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
425
429
|
if (getClientId() !== null) {
|
|
426
430
|
// If we have a client_id already, don't retry
|
|
427
431
|
// we should try once to deal with secret rotation
|
package/app/resourceManager.js
CHANGED
|
@@ -29,6 +29,8 @@ export class ResourceManager {
|
|
|
29
29
|
this._serverUrl = null;
|
|
30
30
|
this._clientId = null;
|
|
31
31
|
this._clientSecret = null;
|
|
32
|
+
this._lastResourceSync = 0;
|
|
33
|
+
this._syncThrottleMs = 30000; // 30 seconds
|
|
32
34
|
this._loadFromFile();
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -102,7 +104,7 @@ export class ResourceManager {
|
|
|
102
104
|
this._resources = { ...resourcesDict };
|
|
103
105
|
this._saveToFile();
|
|
104
106
|
} else {
|
|
105
|
-
log.
|
|
107
|
+
log.info("No resource changes detected");
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
|
|
@@ -114,6 +116,70 @@ export class ResourceManager {
|
|
|
114
116
|
return { ...this._resources };
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Check if a resource URL is allowed (registered).
|
|
121
|
+
* @param {string} resource - The full URL to check
|
|
122
|
+
* @returns {boolean} True if allowed, false otherwise
|
|
123
|
+
*/
|
|
124
|
+
isResourceAllowed(resource) {
|
|
125
|
+
// If no resources registered yet, deny
|
|
126
|
+
if (Object.keys(this._resources).length === 0) {
|
|
127
|
+
log.warn("No resources registered - customer must register resources via setup mode");
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Normalize the target URL for comparison
|
|
132
|
+
const resourceParsed = new URL(resource);
|
|
133
|
+
const resourceNormalized = `${resourceParsed.protocol}//${resourceParsed.host}${resourceParsed.pathname || '/'}`;
|
|
134
|
+
|
|
135
|
+
// Check if the exact base_url is registered
|
|
136
|
+
for (const baseUrl of Object.keys(this._resources)) {
|
|
137
|
+
const baseParsed = new URL(baseUrl);
|
|
138
|
+
const baseNormalized = `${baseParsed.protocol}//${baseParsed.host}${baseParsed.pathname || '/'}`;
|
|
139
|
+
|
|
140
|
+
// Allow if target URL starts with registered base URL
|
|
141
|
+
if (resourceNormalized.startsWith(baseNormalized)) {
|
|
142
|
+
log.info(`Resource ${resource} matches registered base_url ${baseUrl}`);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
log.warn(`Resource ${resource} not found in registered resources: ${Object.keys(this._resources)}`);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a resource is allowed, with automatic sync retry if not found.
|
|
153
|
+
* Syncs from broker if resource not found and throttle period has passed.
|
|
154
|
+
* @param {string} resource - The full URL to check
|
|
155
|
+
* @returns {Promise<boolean>} True if allowed, false otherwise
|
|
156
|
+
*/
|
|
157
|
+
async isResourceAllowedWithSync(resource) {
|
|
158
|
+
// First check without syncing
|
|
159
|
+
if (this.isResourceAllowed(resource)) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Resource not found - try syncing from broker if enough time has passed
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
if (now - this._lastResourceSync > this._syncThrottleMs) {
|
|
166
|
+
log.info(`Resource ${resource} not found, syncing from broker...`);
|
|
167
|
+
this._lastResourceSync = now;
|
|
168
|
+
try {
|
|
169
|
+
await this.syncFromBroker();
|
|
170
|
+
// Check again after sync
|
|
171
|
+
if (this.isResourceAllowed(resource)) {
|
|
172
|
+
log.info(`Resource ${resource} found after sync`);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
log.error(`Failed to sync resources: ${error.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
117
183
|
/**
|
|
118
184
|
* Get resource_id for a base_url.
|
|
119
185
|
* @param {string} baseUrl - The base URL to look up
|
|
@@ -177,13 +243,20 @@ export class ResourceManager {
|
|
|
177
243
|
|
|
178
244
|
// Sync from broker response
|
|
179
245
|
this.syncFromCore(resources);
|
|
246
|
+
this._lastResourceSync = Date.now();
|
|
180
247
|
log.info(`✓ Synced ${Object.keys(resources).length} resources from broker`);
|
|
181
248
|
} else {
|
|
182
249
|
log.warn(`Failed to fetch resources: ${response.status} - ${response.data}`);
|
|
183
250
|
}
|
|
184
251
|
|
|
185
252
|
} catch (error) {
|
|
186
|
-
|
|
253
|
+
if (error.response) {
|
|
254
|
+
// HTTP error response
|
|
255
|
+
log.error(`Error fetching resources from broker: ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
|
256
|
+
} else {
|
|
257
|
+
// Error setting up request
|
|
258
|
+
log.error(`Error fetching resources from broker: ${error.message}`);
|
|
259
|
+
}
|
|
187
260
|
}
|
|
188
261
|
}
|
|
189
262
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikidosec/broker-client",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Aikido Broker Client - Runs in customer network to forward requests to internal resources",
|
|
5
5
|
"main": "app/client.js",
|
|
6
6
|
"type": "module",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"axios": "1.13.2",
|
|
36
|
+
"https-proxy-agent": "^7.0.6",
|
|
36
37
|
"ip-address": "10.0.1",
|
|
37
38
|
"native-dns": "0.7.0",
|
|
38
39
|
"socket.io-client": "4.8.1"
|