@aikidosec/broker-client 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,3 +1,95 @@
1
1
  # Aikido Broker Client
2
2
 
3
- Prepare for publishing first package version.
3
+ A secure broker client that runs in your internal network to forward requests from the Aikido platform to your internal resources via WebSocket connections.
4
+
5
+ Securely connect internal resources to Aikido's platform. Each resource gets a unique subdomain: `https://{resource_id}.aikidobroker.com`
6
+
7
+ ## Installation
8
+
9
+ ### Via npm
10
+
11
+ ```bash
12
+ npm install @aikidosec/broker-client
13
+ ```
14
+
15
+ ### Via Docker (Recommended)
16
+
17
+ ```bash
18
+ docker compose up -d
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ 1. **Generate CLIENT_SECRET in Aikido UI**:
24
+ - Navigate to: Settings → Broker Clients → Add New Client
25
+ - Copy the generated `CLIENT_SECRET`
26
+
27
+ 2. **Configure environment** (`.env`):
28
+ ```bash
29
+ CLIENT_SECRET=your_client_secret_here
30
+ ```
31
+
32
+ 3. **Start client**:
33
+ ```bash
34
+ # Using Docker
35
+ docker compose up -d
36
+
37
+ # Using Node.js directly
38
+ npm start
39
+ ```
40
+
41
+ 4. **Register resources via Aikido UI** - resources are managed through the Aikido platform
42
+
43
+ 5. **Access resources from aikido via subdomain URLs:**
44
+
45
+ ```bash
46
+ https://abc-123.aikidobroker.com/api/endpoint
47
+ ```
48
+
49
+ Resource IDs are displayed in the Aikido UI when you register them.
50
+
51
+ ## Environment Variables
52
+
53
+ **Required:**
54
+ - `CLIENT_SECRET` - Unique client identifier (generate in Aikido UI: Settings → Broker Clients → Add New Client)
55
+
56
+ **Optional:**
57
+ - `ALLOWED_INTERNAL_SUBNETS` - Comma-separated subnet whitelist (e.g., `10.0.0.0/8,172.16.0.0/12`)
58
+ - `DNS_SERVERS` - Custom DNS servers for internal hostname resolution
59
+ - `CUSTOM_CA_BUNDLE` - Path to CA cert for self-signed certificates
60
+ - `HTTP_PROXY` - HTTP proxy for outbound requests to internal resources (e.g., `http://proxy.company.local:8080`)
61
+
62
+ ## How It Works
63
+
64
+ 1. **Client Generation**: Generate a CLIENT_SECRET in the Aikido UI before deployment
65
+ 2. **Client Registration**: On first startup, the client registers with the broker using the CLIENT_SECRET
66
+ 3. **Resource Management**: Resources are registered via the Aikido UI and synced to the client
67
+ 4. **Subdomain Mapping**: Each resource gets a unique subdomain for external access
68
+ 5. **Secure Proxying**: All requests are authenticated and proxied to your internal services
69
+
70
+ ## Example Configuration
71
+
72
+ ```bash
73
+ # .env file
74
+ CLIENT_SECRET=aikido_broker_123_123_123456789
75
+ ALLOWED_INTERNAL_SUBNETS=10.0.0.0/8,172.16.0.0/12
76
+ HTTP_PROXY=http://proxy.company.local:8080
77
+ ```
78
+
79
+ After registering resources through the Aikido UI, they will be accessible via subdomains like:
80
+ - `https://xyz-123.aikidobroker.com` → routes to your registered internal API
81
+ - `https://abc-456.aikidobroker.com` → routes to your registered internal service
82
+
83
+ ## Troubleshooting
84
+
85
+ ```bash
86
+ # View logs
87
+ docker compose logs -f
88
+
89
+ # Check synced resources
90
+ docker compose exec broker-client cat /config/client_resources.json
91
+
92
+ # Verify connection
93
+ docker compose logs | grep "Connected to broker"
94
+ ```
95
+
package/app/client.js ADDED
@@ -0,0 +1,529 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Broker Client - client that runs in customer network, receives requests via WebSocket
5
+ * and forwards them to internal resources
6
+ */
7
+
8
+ import { io } from 'socket.io-client';
9
+ import axios from 'axios';
10
+ import { URL } from 'url';
11
+ import { Address4, Address6 } from 'ip-address';
12
+ import dns from 'native-dns';
13
+ import fs from 'fs';
14
+ import { ResourceManager } from './resourceManager.js';
15
+
16
+ // Configure logging
17
+ const log = {
18
+ info: (msg) => console.log(`[INFO] ${new Date().toISOString()} - ${msg}`),
19
+ warn: (msg) => console.warn(`[WARN] ${new Date().toISOString()} - ${msg}`),
20
+ error: (msg) => console.error(`[ERROR] ${new Date().toISOString()} - ${msg}`),
21
+ debug: (msg) => console.log(`[DEBUG] ${new Date().toISOString()} - ${msg}`)
22
+ };
23
+
24
+ // Broker Server Configuration
25
+ const SERVER_URL = "https://broker.aikidobroker.com";
26
+
27
+ // Client Configuration (from environment)
28
+ const CLIENT_SECRET = process.env.CLIENT_SECRET;
29
+ const ALLOWED_SUBNETS = process.env.ALLOWED_INTERNAL_SUBNETS
30
+ ? process.env.ALLOWED_INTERNAL_SUBNETS.split(',').map(s => s.trim()).filter(s => s)
31
+ : [];
32
+
33
+ // Optional: Custom DNS servers for internal hostname resolution
34
+ const DNS_SERVERS = process.env.DNS_SERVERS
35
+ ? process.env.DNS_SERVERS.split(',').map(s => s.trim()).filter(s => s)
36
+ : null;
37
+
38
+ // Optional: Custom CA certificate bundle for self-signed certificates
39
+ const CUSTOM_CA_BUNDLE = process.env.CUSTOM_CA_BUNDLE || null;
40
+
41
+ // Optional: HTTP proxy for outbound requests to internal resources
42
+ const HTTP_PROXY = process.env.HTTP_PROXY || null;
43
+
44
+ // Determine TLS verification setting
45
+ let httpsAgent = null;
46
+ if (CUSTOM_CA_BUNDLE) {
47
+ const https = await import('https');
48
+ const ca = fs.readFileSync(CUSTOM_CA_BUNDLE);
49
+ httpsAgent = new https.Agent({ ca });
50
+ log.info(`Using custom CA bundle: ${CUSTOM_CA_BUNDLE}`);
51
+ } else {
52
+ log.info("Using system CA certificates for TLS verification");
53
+ }
54
+
55
+ // Configure axios defaults
56
+ const axiosConfig = {
57
+ timeout: 30000,
58
+ maxRedirects: 5,
59
+ httpsAgent
60
+ };
61
+
62
+ if (HTTP_PROXY) {
63
+ const proxyUrl = new URL(HTTP_PROXY);
64
+ axiosConfig.proxy = {
65
+ host: proxyUrl.hostname,
66
+ port: proxyUrl.port || 80,
67
+ protocol: proxyUrl.protocol
68
+ };
69
+ log.info(`Using HTTP proxy for internal requests: ${HTTP_PROXY}`);
70
+ } else {
71
+ log.info("No HTTP proxy configured for internal requests");
72
+ }
73
+
74
+ // Create axios instance for internal requests
75
+ const internalHttpClient = axios.create(axiosConfig);
76
+
77
+ // Initialize ResourceManager
78
+ const resourceManager = new ResourceManager();
79
+
80
+ // Cache for client_id
81
+ let _clientIdCache = null;
82
+
83
+ /**
84
+ * Get the client ID from cache or read from file.
85
+ * Returns null if not registered yet.
86
+ */
87
+ function getClientId() {
88
+ if (_clientIdCache !== null) {
89
+ return _clientIdCache;
90
+ }
91
+
92
+ const clientIdPath = '/config/client_id';
93
+ if (fs.existsSync(clientIdPath)) {
94
+ try {
95
+ _clientIdCache = fs.readFileSync(clientIdPath, 'utf8').trim();
96
+ return _clientIdCache;
97
+ } catch (e) {
98
+ log.error(`Failed to read client_id: ${e.message}`);
99
+ }
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Resolve hostname using custom DNS servers if configured.
107
+ * Falls back to system DNS if DNS_SERVERS not set.
108
+ */
109
+ async function resolveInternalHostname(hostname) {
110
+ if (!DNS_SERVERS || DNS_SERVERS.length === 0) {
111
+ // Use system DNS (default behavior)
112
+ try {
113
+ const dnsPromises = await import('dns/promises');
114
+ const result = await dnsPromises.resolve4(hostname);
115
+ const ip = result[0];
116
+ log.debug(`Resolved ${hostname} to ${ip} (system DNS)`);
117
+ return ip;
118
+ } catch (e) {
119
+ log.error(`Failed to resolve ${hostname}: ${e.message}`);
120
+ throw new Error(`Could not resolve hostname: ${hostname}`);
121
+ }
122
+ }
123
+
124
+ // Use custom DNS servers
125
+ return new Promise((resolve, reject) => {
126
+ const question = dns.Question({
127
+ name: hostname,
128
+ type: 'A',
129
+ });
130
+
131
+ const req = dns.Request({
132
+ question,
133
+ server: { address: DNS_SERVERS[0], port: 53, type: 'udp' },
134
+ timeout: 5000,
135
+ });
136
+
137
+ req.on('timeout', () => {
138
+ reject(new Error('DNS resolution timeout'));
139
+ });
140
+
141
+ req.on('message', (err, answer) => {
142
+ if (err) {
143
+ reject(err);
144
+ return;
145
+ }
146
+
147
+ const addresses = answer.answer
148
+ .filter(a => a.type === 1) // A records
149
+ .map(a => a.address);
150
+
151
+ if (addresses.length > 0) {
152
+ log.info(`Resolved ${hostname} to ${addresses[0]} (custom DNS: ${DNS_SERVERS})`);
153
+ resolve(addresses[0]);
154
+ } else {
155
+ reject(new Error(`No A records found for ${hostname}`));
156
+ }
157
+ });
158
+
159
+ req.send();
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Check if URL points to internal resource
165
+ */
166
+ function isInternalUrl(url) {
167
+ try {
168
+ const parsedUrl = new URL(url);
169
+ const hostname = parsedUrl.hostname;
170
+
171
+ if (!hostname) {
172
+ return false;
173
+ }
174
+
175
+ // Check if hostname is already an IP address
176
+ let ip;
177
+ try {
178
+ const addr4 = new Address4(hostname);
179
+ if (addr4.isValid()) {
180
+ ip = hostname;
181
+ log.info(`Hostname ${hostname} is already an IP address`);
182
+ }
183
+ } catch (e) {
184
+ // Not IPv4, try IPv6
185
+ try {
186
+ const addr6 = new Address6(hostname);
187
+ if (addr6.isValid()) {
188
+ ip = hostname;
189
+ log.info(`Hostname ${hostname} is already an IPv6 address`);
190
+ }
191
+ } catch (e2) {
192
+ // Not an IP, allow it through if we have custom DNS configured
193
+ // The actual resolution will happen in the forward_request handler
194
+ if (DNS_SERVERS !== null && DNS_SERVERS.length > 0) {
195
+ log.debug(`Hostname ${hostname} will be resolved with custom DNS during forward`);
196
+ return true;
197
+ }
198
+
199
+ // For system DNS, we can't do synchronous lookup here
200
+ // Allow all private hostnames through and validate during forward
201
+ log.debug(`Hostname ${hostname} will be validated during forward`);
202
+ return true;
203
+ }
204
+ }
205
+
206
+ // If we have an IP, validate it
207
+ if (ip) {
208
+ const addr = new Address4(ip);
209
+
210
+ // Block external/public IPs (only allow RFC 1918 private IPs)
211
+ if (!addr.isPrivate()) {
212
+ log.warn(`Blocked external/public IP: ${ip}`);
213
+ return false;
214
+ }
215
+
216
+ // Block cloud metadata endpoints (link-local addresses)
217
+ if (addr.isLinkLocal()) {
218
+ log.warn(`Blocked link-local/metadata IP: ${ip} (169.254.x.x)`);
219
+ return false;
220
+ }
221
+
222
+ // If no allowed subnets defined, allow all private IPs (except link-local)
223
+ if (ALLOWED_SUBNETS.length === 0) {
224
+ log.info("No ALLOWED_SUBNETS defined, allowing all private IPs");
225
+ return true;
226
+ }
227
+
228
+ // Check if IP is in allowed subnets
229
+ for (const subnetStr of ALLOWED_SUBNETS) {
230
+ const subnet = new Address4(subnetStr.trim());
231
+ if (addr.isInSubnet(subnet)) {
232
+ log.info(`IP ${ip} is in allowed subnet ${subnetStr}`);
233
+ return true;
234
+ }
235
+ }
236
+
237
+ log.warn(`IP ${ip} not in allowed subnets`);
238
+ return false;
239
+ }
240
+
241
+ // Hostname that needs resolution - allow it through for now
242
+ return true;
243
+ } catch (e) {
244
+ log.error(`Error checking if URL is internal: ${e.message}`);
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Check if a resource is registered with the broker.
251
+ */
252
+ function isResourceAllowed(resource) {
253
+ const registeredResources = resourceManager.getResources();
254
+
255
+ // If no resources registered yet, deny (customer must register resources first)
256
+ if (Object.keys(registeredResources).length === 0) {
257
+ log.warn("No resources registered - customer must register resources via setup mode");
258
+ return false;
259
+ }
260
+
261
+ // Normalize the target URL for comparison
262
+ const resourceParsed = new URL(resource);
263
+ const resourceNormalized = `${resourceParsed.protocol}//${resourceParsed.host}${resourceParsed.pathname || '/'}`;
264
+
265
+ // Check if the exact base_url is registered
266
+ for (const baseUrl of Object.keys(registeredResources)) {
267
+ const baseParsed = new URL(baseUrl);
268
+ const baseNormalized = `${baseParsed.protocol}//${baseParsed.host}${baseParsed.pathname || '/'}`;
269
+
270
+ // Allow if target URL starts with registered base URL
271
+ if (resourceNormalized.startsWith(baseNormalized)) {
272
+ log.info(`Resource ${resource} matches registered base_url ${baseUrl}`);
273
+ return true;
274
+ }
275
+ }
276
+
277
+ log.warn(`Resource ${resource} not found in registered resources: ${Object.keys(registeredResources)}`);
278
+ return false;
279
+ }
280
+
281
+ // Create Socket.IO client
282
+ const socket = io(SERVER_URL, {
283
+ auth: {
284
+ client_secret: CLIENT_SECRET
285
+ },
286
+ transports: ['websocket', 'polling'],
287
+ reconnection: true,
288
+ reconnectionAttempts: Infinity,
289
+ reconnectionDelay: 1000,
290
+ reconnectionDelayMax: 30000,
291
+ randomizationFactor: 0.5
292
+ });
293
+
294
+ // Socket.IO event handlers
295
+ socket.on('connect', async () => {
296
+ log.info('✓ Connected to broker server');
297
+
298
+ const clientId = getClientId();
299
+
300
+ if (clientId) {
301
+ // Configure ResourceManager for broker sync
302
+ resourceManager.configureSync(SERVER_URL, clientId, CLIENT_SECRET);
303
+
304
+ // Sync resources from broker on connect
305
+ await resourceManager.syncFromBroker();
306
+ } else {
307
+ log.warn("No client_id found, skipping resource sync");
308
+ }
309
+ });
310
+
311
+ socket.on('disconnect', () => {
312
+ log.warn("Disconnected from broker server");
313
+ });
314
+
315
+ socket.on('forward_request', async (data, callback) => {
316
+ /**
317
+ * Receive request from broker server and forward to internal resource
318
+ * Uses customer's internal DNS for resolution
319
+ * Returns response via Socket.IO acknowledgement (callback)
320
+ */
321
+ const requestId = data.request_id;
322
+ const targetUrl = data.target_url;
323
+ const method = data.method || 'GET';
324
+ const headers = data.headers || {};
325
+ const body = data.body;
326
+
327
+ log.info(`Received forward request ${requestId} for ${targetUrl}`);
328
+
329
+ // Security: Only allow requests to internal resources and allowed resources
330
+ if (!isInternalUrl(targetUrl)) {
331
+ callback({
332
+ request_id: requestId,
333
+ status_code: 403,
334
+ headers: {},
335
+ body: 'Target URL is not an allowed internal resource'
336
+ });
337
+ return;
338
+ }
339
+
340
+ if (!isResourceAllowed(targetUrl)) {
341
+ callback({
342
+ request_id: requestId,
343
+ status_code: 403,
344
+ headers: {},
345
+ body: 'Target URL is not in the allowed resources list'
346
+ });
347
+ return;
348
+ }
349
+
350
+ try {
351
+ // Resolve DNS if custom DNS servers configured
352
+ const parsed = new URL(targetUrl);
353
+ let resolvedUrl = targetUrl;
354
+
355
+ if (DNS_SERVERS && parsed.hostname) {
356
+ try {
357
+ const resolvedIp = await resolveInternalHostname(parsed.hostname);
358
+ // Replace hostname with resolved IP
359
+ resolvedUrl = targetUrl.replace(parsed.hostname, resolvedIp);
360
+ log.info(`Using resolved URL: ${resolvedUrl}`);
361
+ } catch (e) {
362
+ log.warn(`DNS resolution failed, trying original URL: ${e.message}`);
363
+ }
364
+ }
365
+
366
+ // Forward the request to the internal resource
367
+ const response = await internalHttpClient.request({
368
+ method,
369
+ url: resolvedUrl,
370
+ headers,
371
+ data: body,
372
+ validateStatus: () => true // Accept any status code
373
+ });
374
+
375
+ log.info(`Successfully forwarded request ${requestId} to ${targetUrl}, status: ${response.status}`);
376
+
377
+ // Return response via acknowledgement
378
+ callback({
379
+ request_id: requestId,
380
+ status_code: response.status,
381
+ headers: response.headers,
382
+ body: typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
383
+ });
384
+
385
+ } catch (error) {
386
+ log.error(`Error forwarding request ${requestId} to ${targetUrl}: ${error.message}`);
387
+ callback({
388
+ request_id: requestId,
389
+ status_code: 502,
390
+ headers: {},
391
+ body: `Error reaching internal resource: ${error.message}`
392
+ });
393
+ }
394
+ });
395
+
396
+ /**
397
+ * Register this client with the broker server
398
+ */
399
+ async function registerWithServer() {
400
+ // Check if already registered
401
+ if (getClientId() !== null) {
402
+ log.info("✓ Client already registered (client_id file exists)");
403
+ return;
404
+ }
405
+
406
+ const serverUrl = `${SERVER_URL}/broker-reserved/register`;
407
+
408
+ log.info(`Attempting to register with server at ${serverUrl}`);
409
+
410
+ const maxRetries = 10;
411
+ const retryDelay = 5000;
412
+
413
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
414
+ try {
415
+ const response = await axios.post(serverUrl, {
416
+ client_secret: CLIENT_SECRET
417
+ }, {
418
+ timeout: 10000
419
+ });
420
+
421
+ if (response.status === 200) {
422
+ const clientId = response.data.client_id;
423
+ log.info(`✓ Successfully registered with broker server as ${clientId}`);
424
+
425
+ // Save client_id to file and cache
426
+ _clientIdCache = clientId;
427
+ try {
428
+ fs.mkdirSync('/config', { recursive: true });
429
+ fs.writeFileSync('/config/client_id', clientId);
430
+ log.info("💾 Saved client_id to /config/client_id");
431
+ } catch (e) {
432
+ log.warn(`Could not save client_id file: ${e.message}`);
433
+ }
434
+ return;
435
+ } else if (response.status === 409) {
436
+ log.info("✓ Client already registered with server (this is OK)");
437
+ // Try to extract client_id from response if available
438
+ try {
439
+ const clientId = response.data.client_id;
440
+ if (clientId) {
441
+ _clientIdCache = clientId;
442
+ fs.mkdirSync('/config', { recursive: true });
443
+ fs.writeFileSync('/config/client_id', clientId);
444
+ log.info("💾 Saved client_id to /config/client_id");
445
+ }
446
+ } catch (e) {
447
+ log.warn(`Could not save client_id file: ${e.message}`);
448
+ }
449
+ return;
450
+ } else {
451
+ log.warn(`Registration attempt ${attempt + 1} failed: ${response.status} - ${response.data}`);
452
+ }
453
+ } catch (error) {
454
+ log.warn(`Registration attempt ${attempt + 1} failed: ${error.name}: ${error.message}`);
455
+ }
456
+
457
+ if (attempt < maxRetries - 1) {
458
+ log.info(`Retrying in ${retryDelay / 1000} seconds...`);
459
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
460
+ }
461
+ }
462
+
463
+ log.error("Failed to register with broker server after all retries");
464
+ }
465
+
466
+ /**
467
+ * Background task that syncs resources from broker every 3 minutes.
468
+ * Runs continuously while client is connected.
469
+ */
470
+ async function resourceSyncTask() {
471
+ await new Promise(resolve => setTimeout(resolve, 15000)); // Initial delay before first sync
472
+
473
+ while (true) {
474
+ try {
475
+ await resourceManager.syncFromBroker();
476
+ } catch (error) {
477
+ log.error(`Error in resource_sync_task: ${error.message}`);
478
+ }
479
+
480
+ // Wait 3 minutes before next sync
481
+ await new Promise(resolve => setTimeout(resolve, 180000));
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Main client function
487
+ */
488
+ async function main() {
489
+ log.info("Starting Broker Client");
490
+ log.info(`Allowed internal subnets: ${ALLOWED_SUBNETS.join(', ') || 'None'}`);
491
+
492
+ // Load registered resources from ResourceManager
493
+ const registeredResources = resourceManager.getResources();
494
+ log.info(`Registered resources: ${Object.keys(registeredResources).length > 0 ? Object.keys(registeredResources).join(', ') : 'None'}`);
495
+
496
+ log.info(`Server URL: ${SERVER_URL}`);
497
+ if (HTTP_PROXY) {
498
+ log.info(`HTTP Proxy: ${HTTP_PROXY}`);
499
+ } else {
500
+ log.info("HTTP Proxy: Not configured");
501
+ }
502
+
503
+ // Register with server (creates client_id file)
504
+ await registerWithServer();
505
+
506
+ // Get client_id after registration
507
+ const clientId = getClientId();
508
+
509
+ if (clientId) {
510
+ log.info(`Client ID: ${clientId}`);
511
+ // Configure ResourceManager for syncing with server
512
+ resourceManager.configureSync(SERVER_URL, clientId, CLIENT_SECRET);
513
+ } else {
514
+ log.warn("No client_id available after registration");
515
+ }
516
+
517
+ // Start background task to sync resources every 3 minutes
518
+ resourceSyncTask().catch(err => log.error(`Resource sync task error: ${err.message}`));
519
+ log.info("Started resource sync background task");
520
+
521
+ // Keep process alive
522
+ log.info("Client running...");
523
+ }
524
+
525
+ // Start the client
526
+ main().catch(error => {
527
+ log.error(`Fatal error: ${error.message}`);
528
+ process.exit(1);
529
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Resource Manager for Client
3
+ * Manages local resource configuration with JSON persistence.
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import axios from 'axios';
9
+
10
+ const log = {
11
+ info: (msg) => console.log(`[INFO] ${new Date().toISOString()} - ResourceManager - ${msg}`),
12
+ warn: (msg) => console.warn(`[WARN] ${new Date().toISOString()} - ResourceManager - ${msg}`),
13
+ error: (msg) => console.error(`[ERROR] ${new Date().toISOString()} - ResourceManager - ${msg}`),
14
+ debug: (msg) => console.log(`[DEBUG] ${new Date().toISOString()} - ResourceManager - ${msg}`)
15
+ };
16
+
17
+ /**
18
+ * Manages resource configurations for the client.
19
+ * Stores {base_url: resource_id} mappings with JSON persistence.
20
+ */
21
+ export class ResourceManager {
22
+ /**
23
+ * Initialize ResourceManager.
24
+ * @param {string} configFile - Path to JSON file for persisting resources
25
+ */
26
+ constructor(configFile = '/config/client_resources.json') {
27
+ this.configFile = configFile;
28
+ this._resources = {}; // {base_url: resource_id}
29
+ this._serverUrl = null;
30
+ this._clientId = null;
31
+ this._clientSecret = null;
32
+ this._loadFromFile();
33
+ }
34
+
35
+ /**
36
+ * Configure parameters for syncing with broker server.
37
+ * @param {string} serverUrl - Broker server URL
38
+ * @param {string} clientId - Client ID
39
+ * @param {string} clientSecret - Client secret
40
+ */
41
+ configureSync(serverUrl, clientId, clientSecret) {
42
+ this._serverUrl = serverUrl;
43
+ this._clientId = clientId;
44
+ this._clientSecret = clientSecret;
45
+ }
46
+
47
+ /**
48
+ * Load resources from JSON file if it exists
49
+ */
50
+ _loadFromFile() {
51
+ if (fs.existsSync(this.configFile)) {
52
+ try {
53
+ const data = fs.readFileSync(this.configFile, 'utf8');
54
+ this._resources = JSON.parse(data);
55
+ log.info(`✓ Loaded ${Object.keys(this._resources).length} resources from ${this.configFile}`);
56
+ } catch (error) {
57
+ log.error(`Failed to load resources from ${this.configFile}: ${error.message}`);
58
+ this._resources = {};
59
+ }
60
+ } else {
61
+ log.info(`No existing resources file at ${this.configFile}, starting fresh`);
62
+ this._resources = {};
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Save resources to JSON file
68
+ */
69
+ _saveToFile() {
70
+ try {
71
+ // Ensure directory exists
72
+ const dir = path.dirname(this.configFile);
73
+ fs.mkdirSync(dir, { recursive: true });
74
+
75
+ fs.writeFileSync(this.configFile, JSON.stringify(this._resources, null, 2));
76
+ log.info(`💾 Saved ${Object.keys(this._resources).length} resources to ${this.configFile}`);
77
+ } catch (error) {
78
+ log.error(`Failed to save resources to ${this.configFile}: ${error.message}`);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Sync resources from Core API data.
84
+ * @param {Object} resourcesDict - Dictionary of {base_url: resource_id} from Core API
85
+ */
86
+ syncFromCore(resourcesDict) {
87
+ const currentKeys = new Set(Object.keys(this._resources));
88
+ const newKeys = new Set(Object.keys(resourcesDict));
89
+
90
+ const added = [...newKeys].filter(k => !currentKeys.has(k));
91
+ const removed = [...currentKeys].filter(k => !newKeys.has(k));
92
+ const updated = [...newKeys].filter(k =>
93
+ currentKeys.has(k) && resourcesDict[k] !== this._resources[k]
94
+ );
95
+
96
+ if (added.length > 0 || removed.length > 0 || updated.length > 0) {
97
+ log.info(
98
+ `Resource changes: +${added.length} added, ` +
99
+ `-${removed.length} removed, ~${updated.length} updated`
100
+ );
101
+
102
+ this._resources = { ...resourcesDict };
103
+ this._saveToFile();
104
+ } else {
105
+ log.debug("No resource changes detected");
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Get all resources.
111
+ * @returns {Object} Dictionary of {base_url: resource_id}
112
+ */
113
+ getResources() {
114
+ return { ...this._resources };
115
+ }
116
+
117
+ /**
118
+ * Get resource_id for a base_url.
119
+ * @param {string} baseUrl - The base URL to look up
120
+ * @returns {string|null} resource_id if found, null otherwise
121
+ */
122
+ getResourceId(baseUrl) {
123
+ return this._resources[baseUrl] || null;
124
+ }
125
+
126
+ /**
127
+ * Add or update a resource.
128
+ * @param {string} baseUrl - The base URL
129
+ * @param {string} resourceId - The resource UUID
130
+ */
131
+ addResource(baseUrl, resourceId) {
132
+ this._resources[baseUrl] = resourceId;
133
+ this._saveToFile();
134
+ log.info(`Added resource: ${baseUrl} → ${resourceId}`);
135
+ }
136
+
137
+ /**
138
+ * Remove a resource by base_url.
139
+ * @param {string} baseUrl - The base URL to remove
140
+ * @returns {boolean} True if removed, false if not found
141
+ */
142
+ removeResource(baseUrl) {
143
+ if (baseUrl in this._resources) {
144
+ const resourceId = this._resources[baseUrl];
145
+ delete this._resources[baseUrl];
146
+ this._saveToFile();
147
+ log.info(`Removed resource: ${baseUrl} (was ${resourceId})`);
148
+ return true;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Fetch and sync resources from broker server.
155
+ * Called on connect and periodically to stay in sync with Core API.
156
+ */
157
+ async syncFromBroker() {
158
+ if (!this._serverUrl || !this._clientId || !this._clientSecret) {
159
+ log.warn("ResourceManager not configured for broker sync");
160
+ return;
161
+ }
162
+
163
+ try {
164
+ log.info("Fetching resources from broker...");
165
+
166
+ const url = `${this._serverUrl}/broker_reserved/${this._clientId}/resources`;
167
+
168
+ const response = await axios.get(url, {
169
+ headers: {
170
+ "broker-client-secret": this._clientSecret
171
+ },
172
+ timeout: 10000
173
+ });
174
+
175
+ if (response.status === 200) {
176
+ const resources = response.data.resources || {};
177
+
178
+ // Sync from broker response
179
+ this.syncFromCore(resources);
180
+ log.info(`✓ Synced ${Object.keys(resources).length} resources from broker`);
181
+ } else {
182
+ log.warn(`Failed to fetch resources: ${response.status} - ${response.data}`);
183
+ }
184
+
185
+ } catch (error) {
186
+ log.error(`Error fetching resources from broker: ${error.message}`);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Register a new resource with the broker (which forwards to Core API).
192
+ * Server generates the UUID to prevent duplicates.
193
+ * @param {string} baseUrl - The base URL
194
+ * @returns {Promise<string>} The server-generated resource_id
195
+ */
196
+ async registerResourceWithBroker(baseUrl) {
197
+ if (!this._serverUrl || !this._clientId || !this._clientSecret) {
198
+ throw new Error("ResourceManager not configured for broker sync");
199
+ }
200
+
201
+ log.info(`Registering resource with broker: ${baseUrl}`);
202
+
203
+ const url = `${this._serverUrl}/clients/${this._clientId}/resources`;
204
+
205
+ try {
206
+ const response = await axios.post(
207
+ url,
208
+ { base_url: baseUrl },
209
+ {
210
+ headers: {
211
+ "broker-client-secret": this._clientSecret
212
+ },
213
+ timeout: 10000
214
+ }
215
+ );
216
+
217
+ if (response.status === 200 || response.status === 201) {
218
+ const resourceId = response.data.resource_id;
219
+ if (!resourceId) {
220
+ throw new Error("Server did not return resource_id");
221
+ }
222
+ log.info(`✓ Resource registered with broker: ${baseUrl} → ${resourceId}`);
223
+ return resourceId;
224
+ } else {
225
+ const errorText = response.data;
226
+ log.error(`Failed to register resource: ${response.status} - ${errorText}`);
227
+ throw new Error(`Broker returned ${response.status}: ${errorText}`);
228
+ }
229
+ } catch (error) {
230
+ if (error.response) {
231
+ log.error(`Failed to register resource: ${error.response.status} - ${error.response.data}`);
232
+ throw new Error(`Broker returned ${error.response.status}: ${error.response.data}`);
233
+ }
234
+ throw error;
235
+ }
236
+ }
237
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@aikidosec/broker-client",
3
- "version": "0.0.1",
4
- "description": "Prepare for publishing first package version.",
5
- "license": "SEE LICENSE IN LICENSE.md"
3
+ "version": "0.0.3",
4
+ "description": "Aikido Broker Client - Runs in customer network to forward requests to internal resources",
5
+ "main": "app/client.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node app/client.js",
9
+ "dev": "node --watch app/client.js"
10
+ },
11
+ "keywords": [
12
+ "broker",
13
+ "proxy",
14
+ "websocket",
15
+ "security",
16
+ "aikido",
17
+ "internal-network",
18
+ "secure-proxy"
19
+ ],
20
+ "author": "Aikido Security",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/AikidoSec/broker.git",
25
+ "directory": "client"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/AikidoSec/broker/issues"
29
+ },
30
+ "homepage": "https://github.com/AikidoSec/broker#readme",
31
+ "engines": {
32
+ "node": ">=20.0.0"
33
+ },
34
+ "dependencies": {
35
+ "axios": "1.13.2",
36
+ "ip-address": "10.0.1",
37
+ "native-dns": "0.7.0",
38
+ "socket.io-client": "4.8.1"
39
+ },
40
+ "files": [
41
+ "app/",
42
+ "README.md",
43
+ "LICENSE"
44
+ ]
6
45
  }