@aikidosec/broker-client 0.0.1 → 0.0.2
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 +93 -1
- package/app/client.js +529 -0
- package/app/resourceManager.js +237 -0
- package/package.json +42 -3
package/README.md
CHANGED
|
@@ -1,3 +1,95 @@
|
|
|
1
1
|
# Aikido Broker Client
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.2",
|
|
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
|
}
|