claw-dashboard 1.9.0 → 2.1.0
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/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5285 -566
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -6
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1934 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1056 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- package/tests/worker-pool.test.js +522 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Server Module for Claw Dashboard
|
|
3
|
+
* Provides HTTP API for remote access to dashboard data
|
|
4
|
+
* Includes rate limiting and configurable CORS for security
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import http from 'http';
|
|
8
|
+
import url from 'url';
|
|
9
|
+
import logger from './logger.js';
|
|
10
|
+
import config from './config.js';
|
|
11
|
+
import { ApiKeyAuth } from './security.js';
|
|
12
|
+
|
|
13
|
+
const { WEB, DASHBOARD_VERSION } = config;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Rate limiter for web server requests
|
|
17
|
+
* Tracks requests per IP address with sliding window
|
|
18
|
+
*/
|
|
19
|
+
class WebRateLimiter {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.enabled = options.enabled ?? WEB.RATE_LIMIT.ENABLED;
|
|
22
|
+
this.windowMs = options.windowMs ?? WEB.RATE_LIMIT.WINDOW_MS;
|
|
23
|
+
this.maxRequests = options.maxRequests ?? WEB.RATE_LIMIT.MAX_REQUESTS;
|
|
24
|
+
this.trustProxy = options.trustProxy ?? WEB.RATE_LIMIT.TRUST_PROXY;
|
|
25
|
+
this.requests = new Map(); // ip -> [{ timestamp, count }]
|
|
26
|
+
this.blocked = new Map(); // ip -> unblockTime
|
|
27
|
+
|
|
28
|
+
// Start cleanup interval
|
|
29
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), this.windowMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get client IP from request
|
|
34
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
35
|
+
* @returns {string} Client IP address
|
|
36
|
+
*/
|
|
37
|
+
getClientIp(req) {
|
|
38
|
+
// Check for X-Forwarded-For if behind proxy
|
|
39
|
+
if (this.trustProxy) {
|
|
40
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
41
|
+
if (forwarded) {
|
|
42
|
+
// X-Forwarded-For can be comma-separated, take the first (client)
|
|
43
|
+
return forwarded.split(',')[0].trim();
|
|
44
|
+
}
|
|
45
|
+
const realIp = req.headers['x-real-ip'];
|
|
46
|
+
if (realIp) {
|
|
47
|
+
return realIp;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fall back to connection remote address
|
|
52
|
+
return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if request is allowed or rate limited
|
|
57
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
58
|
+
* @returns {object} Result with allowed boolean and retryAfter
|
|
59
|
+
*/
|
|
60
|
+
check(req) {
|
|
61
|
+
if (!this.enabled) {
|
|
62
|
+
return { allowed: true, remaining: this.maxRequests };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ip = this.getClientIp(req);
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
|
|
68
|
+
// Check if IP is currently blocked
|
|
69
|
+
const unblockTime = this.blocked.get(ip);
|
|
70
|
+
if (unblockTime && now < unblockTime) {
|
|
71
|
+
return {
|
|
72
|
+
allowed: false,
|
|
73
|
+
retryAfter: Math.ceil((unblockTime - now) / 1000),
|
|
74
|
+
ip
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Remove from blocked if time has passed
|
|
79
|
+
if (unblockTime && now >= unblockTime) {
|
|
80
|
+
this.blocked.delete(ip);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get or create request history for this IP
|
|
84
|
+
let history = this.requests.get(ip);
|
|
85
|
+
if (!history) {
|
|
86
|
+
history = [];
|
|
87
|
+
this.requests.set(ip, history);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Filter to only requests within the current window
|
|
91
|
+
const windowStart = now - this.windowMs;
|
|
92
|
+
const validRequests = history.filter(ts => ts > windowStart);
|
|
93
|
+
|
|
94
|
+
// Update history with cleaned up timestamps
|
|
95
|
+
this.requests.set(ip, validRequests);
|
|
96
|
+
|
|
97
|
+
// Check if over limit
|
|
98
|
+
if (validRequests.length >= this.maxRequests) {
|
|
99
|
+
// Calculate retry after based on oldest request
|
|
100
|
+
const oldestRequest = validRequests[0];
|
|
101
|
+
const retryAfter = Math.ceil((oldestRequest + this.windowMs - now) / 1000);
|
|
102
|
+
|
|
103
|
+
// Block this IP temporarily
|
|
104
|
+
this.blocked.set(ip, now + this.windowMs);
|
|
105
|
+
|
|
106
|
+
logger.warn(`[RATE LIMIT] IP ${ip} blocked - exceeded ${this.maxRequests} requests in ${this.windowMs}ms`);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
allowed: false,
|
|
110
|
+
retryAfter: Math.max(1, retryAfter),
|
|
111
|
+
ip
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
allowed: true,
|
|
117
|
+
remaining: this.maxRequests - validRequests.length,
|
|
118
|
+
ip
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Record a request for an IP
|
|
124
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
125
|
+
*/
|
|
126
|
+
record(req) {
|
|
127
|
+
if (!this.enabled) return;
|
|
128
|
+
|
|
129
|
+
const ip = this.getClientIp(req);
|
|
130
|
+
const history = this.requests.get(ip) || [];
|
|
131
|
+
history.push(Date.now());
|
|
132
|
+
this.requests.set(ip, history);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Clean up old entries to prevent memory leaks
|
|
137
|
+
*/
|
|
138
|
+
cleanup() {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const windowStart = now - this.windowMs;
|
|
141
|
+
|
|
142
|
+
// Clean up request history
|
|
143
|
+
for (const [ip, history] of this.requests.entries()) {
|
|
144
|
+
const validRequests = history.filter(ts => ts > windowStart);
|
|
145
|
+
if (validRequests.length === 0) {
|
|
146
|
+
this.requests.delete(ip);
|
|
147
|
+
} else {
|
|
148
|
+
this.requests.set(ip, validRequests);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Clean up blocked entries
|
|
153
|
+
for (const [ip, unblockTime] of this.blocked.entries()) {
|
|
154
|
+
if (now >= unblockTime) {
|
|
155
|
+
this.blocked.delete(ip);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get rate limit status for an IP
|
|
162
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
163
|
+
* @returns {object} Status with count, limit, remaining, resetTime
|
|
164
|
+
*/
|
|
165
|
+
getStatus(req) {
|
|
166
|
+
const ip = this.getClientIp(req);
|
|
167
|
+
|
|
168
|
+
if (!this.enabled) {
|
|
169
|
+
return {
|
|
170
|
+
enabled: false,
|
|
171
|
+
limit: this.maxRequests,
|
|
172
|
+
remaining: this.maxRequests,
|
|
173
|
+
current: 0,
|
|
174
|
+
resetTime: null,
|
|
175
|
+
ip
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const history = this.requests.get(ip) || [];
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
const windowStart = now - this.windowMs;
|
|
182
|
+
const validRequests = history.filter(ts => ts > windowStart);
|
|
183
|
+
|
|
184
|
+
let resetTime = null;
|
|
185
|
+
if (validRequests.length > 0) {
|
|
186
|
+
const oldestRequest = Math.min(...validRequests);
|
|
187
|
+
resetTime = new Date(oldestRequest + this.windowMs).toISOString();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
enabled: true,
|
|
192
|
+
limit: this.maxRequests,
|
|
193
|
+
remaining: Math.max(0, this.maxRequests - validRequests.length),
|
|
194
|
+
current: validRequests.length,
|
|
195
|
+
resetTime,
|
|
196
|
+
ip
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Stop the cleanup interval
|
|
202
|
+
*/
|
|
203
|
+
stop() {
|
|
204
|
+
if (this.cleanupInterval) {
|
|
205
|
+
clearInterval(this.cleanupInterval);
|
|
206
|
+
this.cleanupInterval = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* CORS configuration manager
|
|
213
|
+
* Handles CORS headers based on configuration
|
|
214
|
+
*/
|
|
215
|
+
class CorsManager {
|
|
216
|
+
constructor(options = {}) {
|
|
217
|
+
// Support both old config (string) and new config (object/array)
|
|
218
|
+
this.allowedOrigins = options.allowedOrigins ?? WEB.CORS.ALLOWED_ORIGINS;
|
|
219
|
+
this.allowedMethods = options.allowedMethods ?? WEB.CORS.ALLOWED_METHODS;
|
|
220
|
+
this.allowedHeaders = options.allowedHeaders ?? WEB.CORS.ALLOWED_HEADERS;
|
|
221
|
+
this.credentials = options.credentials ?? WEB.CORS.CREDENTIALS;
|
|
222
|
+
this.maxAge = options.maxAge ?? WEB.CORS.MAX_AGE;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if an origin is allowed
|
|
227
|
+
* @param {string} origin - Request origin
|
|
228
|
+
* @returns {boolean} True if allowed
|
|
229
|
+
*/
|
|
230
|
+
isOriginAllowed(origin) {
|
|
231
|
+
// Allow all origins with '*'
|
|
232
|
+
if (this.allowedOrigins === '*') {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// No origin header (e.g., direct curl request)
|
|
237
|
+
if (!origin) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// If it's an array, check if origin is in the list
|
|
242
|
+
if (Array.isArray(this.allowedOrigins)) {
|
|
243
|
+
return this.allowedOrigins.some(allowed => {
|
|
244
|
+
if (allowed.includes('*')) {
|
|
245
|
+
// Support wildcard patterns like https://*.example.com
|
|
246
|
+
const pattern = allowed.replace(/\*/g, '.*');
|
|
247
|
+
return new RegExp(`^${pattern}$`).test(origin);
|
|
248
|
+
}
|
|
249
|
+
return allowed === origin;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// String comparison
|
|
254
|
+
return this.allowedOrigins === origin;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get CORS headers for a request
|
|
259
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
260
|
+
* @returns {Object} CORS headers
|
|
261
|
+
*/
|
|
262
|
+
getHeaders(req) {
|
|
263
|
+
const origin = req.headers.origin;
|
|
264
|
+
const headers = {
|
|
265
|
+
'Access-Control-Allow-Methods': this.allowedMethods.join(', '),
|
|
266
|
+
'Access-Control-Allow-Headers': this.allowedHeaders.join(', '),
|
|
267
|
+
'Access-Control-Max-Age': this.maxAge.toString(),
|
|
268
|
+
'Content-Type': 'application/json',
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Handle origin
|
|
272
|
+
if (this.allowedOrigins === '*') {
|
|
273
|
+
// When credentials are used with '*', browser rejects it for security
|
|
274
|
+
// So we mirror the request origin when credentials are enabled
|
|
275
|
+
if (this.credentials && origin) {
|
|
276
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
277
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
278
|
+
} else {
|
|
279
|
+
headers['Access-Control-Allow-Origin'] = '*';
|
|
280
|
+
}
|
|
281
|
+
} else if (this.isOriginAllowed(origin)) {
|
|
282
|
+
headers['Access-Control-Allow-Origin'] = origin || '*';
|
|
283
|
+
if (this.credentials) {
|
|
284
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// If origin is not allowed, don't include CORS headers (browser will block)
|
|
288
|
+
|
|
289
|
+
return headers;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check if credentials should be allowed
|
|
294
|
+
* @returns {boolean} True if credentials are allowed
|
|
295
|
+
*/
|
|
296
|
+
allowsCredentials() {
|
|
297
|
+
return this.credentials;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Create CORS headers for HTTP responses (legacy function for backward compatibility)
|
|
303
|
+
* @returns {Object} CORS headers
|
|
304
|
+
* @deprecated Use CorsManager instead
|
|
305
|
+
*/
|
|
306
|
+
function getCorsHeaders() {
|
|
307
|
+
const corsManager = new CorsManager();
|
|
308
|
+
return corsManager.getHeaders({ headers: {} });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Send JSON response
|
|
313
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
314
|
+
* @param {number} statusCode - HTTP status code
|
|
315
|
+
* @param {Object} data - Response data
|
|
316
|
+
* @param {Object} headers - Additional headers
|
|
317
|
+
*/
|
|
318
|
+
function sendJson(res, statusCode, data, headers = {}) {
|
|
319
|
+
res.writeHead(statusCode, { ...headers, 'Content-Type': 'application/json' });
|
|
320
|
+
res.end(JSON.stringify(data, null, 2));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Send error response
|
|
325
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
326
|
+
* @param {number} statusCode - HTTP status code
|
|
327
|
+
* @param {string} message - Error message
|
|
328
|
+
* @param {Object} headers - Additional headers
|
|
329
|
+
* @param {Object} extra - Extra fields to include in response
|
|
330
|
+
*/
|
|
331
|
+
function sendError(res, statusCode, message, headers = {}, extra = {}) {
|
|
332
|
+
sendJson(res, statusCode, { error: message, status: statusCode, ...extra }, headers);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Web Server class for exposing dashboard data via HTTP API
|
|
337
|
+
*/
|
|
338
|
+
export class WebServer {
|
|
339
|
+
constructor(options = {}) {
|
|
340
|
+
this.port = options.port || WEB.DEFAULT_PORT;
|
|
341
|
+
this.host = options.host || WEB.HOST;
|
|
342
|
+
this.server = null;
|
|
343
|
+
this.dataProvider = null;
|
|
344
|
+
this.startTime = Date.now();
|
|
345
|
+
this.requestCount = 0;
|
|
346
|
+
this.errorCount = 0;
|
|
347
|
+
|
|
348
|
+
// Initialize rate limiter
|
|
349
|
+
this.rateLimiter = new WebRateLimiter({
|
|
350
|
+
enabled: options.rateLimit?.enabled ?? WEB.RATE_LIMIT.ENABLED,
|
|
351
|
+
windowMs: options.rateLimit?.windowMs ?? WEB.RATE_LIMIT.WINDOW_MS,
|
|
352
|
+
maxRequests: options.rateLimit?.maxRequests ?? WEB.RATE_LIMIT.MAX_REQUESTS,
|
|
353
|
+
trustProxy: options.rateLimit?.trustProxy ?? WEB.RATE_LIMIT.TRUST_PROXY,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Initialize CORS manager
|
|
357
|
+
this.corsManager = new CorsManager({
|
|
358
|
+
allowedOrigins: options.corsOrigins ?? WEB.CORS.ALLOWED_ORIGINS,
|
|
359
|
+
allowedMethods: options.corsMethods ?? WEB.CORS.ALLOWED_METHODS,
|
|
360
|
+
allowedHeaders: options.corsHeaders ?? WEB.CORS.ALLOWED_HEADERS,
|
|
361
|
+
credentials: options.corsCredentials ?? WEB.CORS.CREDENTIALS,
|
|
362
|
+
maxAge: options.corsMaxAge ?? WEB.CORS.MAX_AGE,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Initialize API key authentication
|
|
366
|
+
this.apiKeyAuth = new ApiKeyAuth({
|
|
367
|
+
enabled: options.auth?.enabled ?? WEB.AUTH.ENABLED,
|
|
368
|
+
headerName: options.auth?.headerName ?? WEB.AUTH.HEADER_NAME,
|
|
369
|
+
scheme: options.auth?.scheme ?? WEB.AUTH.SCHEME,
|
|
370
|
+
keyPrefix: options.auth?.keyPrefix ?? WEB.AUTH.KEY_PREFIX,
|
|
371
|
+
keyLength: options.auth?.keyLength ?? WEB.AUTH.KEY_LENGTH,
|
|
372
|
+
maxKeys: options.auth?.maxKeys ?? WEB.AUTH.MAX_KEYS,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Expose auth management methods
|
|
376
|
+
this.generateApiKey = this.generateApiKey.bind(this);
|
|
377
|
+
this.revokeApiKey = this.revokeApiKey.bind(this);
|
|
378
|
+
this.listApiKeys = this.listApiKeys.bind(this);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Set the data provider function that will supply dashboard data
|
|
383
|
+
* @param {Function} provider - Function that returns dashboard data
|
|
384
|
+
*/
|
|
385
|
+
setDataProvider(provider) {
|
|
386
|
+
this.dataProvider = provider;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Get health status
|
|
391
|
+
* @returns {Object} Health status
|
|
392
|
+
*/
|
|
393
|
+
getHealth() {
|
|
394
|
+
const uptime = Date.now() - this.startTime;
|
|
395
|
+
return {
|
|
396
|
+
status: 'healthy',
|
|
397
|
+
version: DASHBOARD_VERSION,
|
|
398
|
+
uptime: uptime,
|
|
399
|
+
uptimeHuman: this.formatUptime(uptime),
|
|
400
|
+
timestamp: new Date().toISOString(),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Format uptime in human-readable format
|
|
406
|
+
* @param {number} ms - Milliseconds
|
|
407
|
+
* @returns {string} Formatted uptime
|
|
408
|
+
*/
|
|
409
|
+
formatUptime(ms) {
|
|
410
|
+
const seconds = Math.floor(ms / 1000);
|
|
411
|
+
const minutes = Math.floor(seconds / 60);
|
|
412
|
+
const hours = Math.floor(minutes / 60);
|
|
413
|
+
const days = Math.floor(hours / 24);
|
|
414
|
+
|
|
415
|
+
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
|
416
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
|
417
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
418
|
+
return `${seconds}s`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Send rate limit response
|
|
423
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
424
|
+
* @param {Object} rateLimitResult - Rate limit check result
|
|
425
|
+
*/
|
|
426
|
+
sendRateLimitResponse(res, rateLimitResult) {
|
|
427
|
+
const headers = this.corsManager.getHeaders({ headers: {} });
|
|
428
|
+
headers['Retry-After'] = rateLimitResult.retryAfter.toString();
|
|
429
|
+
headers['X-RateLimit-Limit'] = this.rateLimiter.maxRequests.toString();
|
|
430
|
+
headers['X-RateLimit-Remaining'] = '0';
|
|
431
|
+
headers['X-RateLimit-Reset'] = (Date.now() + rateLimitResult.retryAfter * 1000).toString();
|
|
432
|
+
|
|
433
|
+
sendError(res, 429, 'Too many requests', headers, {
|
|
434
|
+
retryAfter: rateLimitResult.retryAfter,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Send CORS-related error (origin not allowed)
|
|
440
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
441
|
+
*/
|
|
442
|
+
sendCorsError(res) {
|
|
443
|
+
sendError(res, 403, 'Origin not allowed', {
|
|
444
|
+
'Content-Type': 'application/json',
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Add rate limit headers to response
|
|
450
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
451
|
+
* @param {Object} rateLimitStatus - Rate limit status
|
|
452
|
+
*/
|
|
453
|
+
addRateLimitHeaders(res, rateLimitStatus) {
|
|
454
|
+
res.setHeader('X-RateLimit-Limit', rateLimitStatus.limit.toString());
|
|
455
|
+
res.setHeader('X-RateLimit-Remaining', rateLimitStatus.remaining.toString());
|
|
456
|
+
if (rateLimitStatus.resetTime) {
|
|
457
|
+
res.setHeader('X-RateLimit-Reset', new Date(rateLimitStatus.resetTime).getTime().toString());
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Send authentication error response
|
|
463
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
464
|
+
* @param {Object} authResult - Authentication result from ApiKeyAuth
|
|
465
|
+
* @param {Object} headers - Additional headers
|
|
466
|
+
*/
|
|
467
|
+
sendAuthError(res, authResult, headers = {}) {
|
|
468
|
+
const errorHeaders = { ...headers };
|
|
469
|
+
|
|
470
|
+
// Add retry-after header if blocked
|
|
471
|
+
if (authResult.retryAfter) {
|
|
472
|
+
errorHeaders['Retry-After'] = authResult.retryAfter.toString();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Add WWW-Authenticate header for 401 responses
|
|
476
|
+
const authScheme = this.apiKeyAuth.scheme || 'Bearer';
|
|
477
|
+
errorHeaders['WWW-Authenticate'] = `${authScheme} realm="Claw Dashboard API"`;
|
|
478
|
+
|
|
479
|
+
const statusCode = authResult.code === 'AUTH_BLOCKED' ? 429 : 401;
|
|
480
|
+
const extra = authResult.retryAfter ? { retryAfter: authResult.retryAfter } : {};
|
|
481
|
+
|
|
482
|
+
sendError(res, statusCode, authResult.error, errorHeaders, { code: authResult.code, ...extra });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Generate a new API key
|
|
487
|
+
* @param {string} name - Human-readable name for the key
|
|
488
|
+
* @returns {Object} Key data including the full key (only shown once)
|
|
489
|
+
*/
|
|
490
|
+
generateApiKey(name) {
|
|
491
|
+
return this.apiKeyAuth.generateKey(name);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Revoke an API key
|
|
496
|
+
* @param {string} keyId - The key ID to revoke
|
|
497
|
+
* @returns {boolean} True if key was found and revoked
|
|
498
|
+
*/
|
|
499
|
+
revokeApiKey(keyId) {
|
|
500
|
+
const revoked = this.apiKeyAuth.revokeKey(keyId);
|
|
501
|
+
if (revoked) {
|
|
502
|
+
logger.info(`[AUTH] Revoked API key: ${keyId}`);
|
|
503
|
+
}
|
|
504
|
+
return revoked;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* List all active API keys
|
|
509
|
+
* @returns {Array} List of key metadata (without actual keys)
|
|
510
|
+
*/
|
|
511
|
+
listApiKeys() {
|
|
512
|
+
return this.apiKeyAuth.listKeys();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Check if authentication is enabled
|
|
517
|
+
* @returns {boolean} True if authentication is enabled
|
|
518
|
+
*/
|
|
519
|
+
isAuthEnabled() {
|
|
520
|
+
return this.apiKeyAuth.isEnabled();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Enable authentication
|
|
525
|
+
*/
|
|
526
|
+
enableAuth() {
|
|
527
|
+
this.apiKeyAuth.enable();
|
|
528
|
+
logger.info('[AUTH] Authentication enabled');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Disable authentication
|
|
533
|
+
*/
|
|
534
|
+
disableAuth() {
|
|
535
|
+
this.apiKeyAuth.disable();
|
|
536
|
+
logger.info('[AUTH] Authentication disabled');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Handle incoming HTTP requests
|
|
541
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
542
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
543
|
+
*/
|
|
544
|
+
async handleRequest(req, res) {
|
|
545
|
+
this.requestCount++;
|
|
546
|
+
const parsedUrl = url.parse(req.url, true);
|
|
547
|
+
const pathname = parsedUrl.pathname;
|
|
548
|
+
|
|
549
|
+
// Get CORS headers
|
|
550
|
+
const corsHeaders = this.corsManager.getHeaders(req);
|
|
551
|
+
|
|
552
|
+
// Check if CORS origin is allowed
|
|
553
|
+
const origin = req.headers.origin;
|
|
554
|
+
if (origin && !this.corsManager.isOriginAllowed(origin)) {
|
|
555
|
+
this.errorCount++;
|
|
556
|
+
logger.warn(`[CORS] Rejected request from disallowed origin: ${origin}`);
|
|
557
|
+
this.sendCorsError(res);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Handle CORS preflight
|
|
562
|
+
if (req.method === 'OPTIONS') {
|
|
563
|
+
res.writeHead(200, corsHeaders);
|
|
564
|
+
res.end();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Check rate limit (skip for health endpoint to allow monitoring)
|
|
569
|
+
if (pathname !== WEB.ENDPOINTS.HEALTH) {
|
|
570
|
+
const rateLimitResult = this.rateLimiter.check(req);
|
|
571
|
+
|
|
572
|
+
if (!rateLimitResult.allowed) {
|
|
573
|
+
this.errorCount++;
|
|
574
|
+
this.sendRateLimitResponse(res, rateLimitResult);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Record this request
|
|
579
|
+
this.rateLimiter.record(req);
|
|
580
|
+
|
|
581
|
+
// Add rate limit headers to successful responses
|
|
582
|
+
const rateLimitStatus = this.rateLimiter.getStatus(req);
|
|
583
|
+
this.addRateLimitHeaders(res, rateLimitStatus);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Check authentication (skip for health endpoint)
|
|
587
|
+
if (pathname !== WEB.ENDPOINTS.HEALTH) {
|
|
588
|
+
const clientIp = this.rateLimiter.getClientIp(req);
|
|
589
|
+
const authResult = this.apiKeyAuth.authenticate(req.headers, clientIp);
|
|
590
|
+
|
|
591
|
+
if (!authResult.authenticated) {
|
|
592
|
+
this.errorCount++;
|
|
593
|
+
logger.warn(`[AUTH] Failed authentication from ${clientIp}: ${authResult.error}`);
|
|
594
|
+
this.sendAuthError(res, authResult, corsHeaders);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Add authenticated key info to response headers
|
|
599
|
+
if (authResult.keyId) {
|
|
600
|
+
res.setHeader('X-Auth-Key-Id', authResult.keyId);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Route requests
|
|
605
|
+
try {
|
|
606
|
+
switch (pathname) {
|
|
607
|
+
case WEB.ENDPOINTS.HEALTH:
|
|
608
|
+
this.handleHealth(req, res, corsHeaders);
|
|
609
|
+
break;
|
|
610
|
+
case WEB.ENDPOINTS.METRICS:
|
|
611
|
+
await this.handleMetrics(req, res, corsHeaders);
|
|
612
|
+
break;
|
|
613
|
+
case WEB.ENDPOINTS.SESSIONS:
|
|
614
|
+
await this.handleSessions(req, res, corsHeaders);
|
|
615
|
+
break;
|
|
616
|
+
case WEB.ENDPOINTS.AGENTS:
|
|
617
|
+
await this.handleAgents(req, res, corsHeaders);
|
|
618
|
+
break;
|
|
619
|
+
case WEB.ENDPOINTS.LOGS:
|
|
620
|
+
await this.handleLogs(req, res, corsHeaders);
|
|
621
|
+
break;
|
|
622
|
+
case WEB.ENDPOINTS.STATUS:
|
|
623
|
+
await this.handleStatus(req, res, corsHeaders);
|
|
624
|
+
break;
|
|
625
|
+
default:
|
|
626
|
+
sendError(res, 404, 'Not found', corsHeaders);
|
|
627
|
+
}
|
|
628
|
+
} catch (err) {
|
|
629
|
+
this.errorCount++;
|
|
630
|
+
logger.error(`Web server error: ${err.message}`);
|
|
631
|
+
sendError(res, 500, 'Internal server error', corsHeaders);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Handle health check endpoint
|
|
637
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
638
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
639
|
+
* @param {Object} corsHeaders - CORS headers
|
|
640
|
+
*/
|
|
641
|
+
handleHealth(req, res, corsHeaders) {
|
|
642
|
+
const health = this.getHealth();
|
|
643
|
+
// Add rate limit info to health endpoint
|
|
644
|
+
const rateLimitStatus = this.rateLimiter.getStatus(req);
|
|
645
|
+
|
|
646
|
+
sendJson(res, 200, {
|
|
647
|
+
...health,
|
|
648
|
+
rateLimit: {
|
|
649
|
+
enabled: rateLimitStatus.enabled,
|
|
650
|
+
limit: rateLimitStatus.limit,
|
|
651
|
+
},
|
|
652
|
+
auth: {
|
|
653
|
+
enabled: this.apiKeyAuth.isEnabled(),
|
|
654
|
+
scheme: this.apiKeyAuth.scheme,
|
|
655
|
+
keyCount: this.apiKeyAuth.getKeyCount(),
|
|
656
|
+
},
|
|
657
|
+
}, corsHeaders);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Handle metrics endpoint
|
|
662
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
663
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
664
|
+
* @param {Object} corsHeaders - CORS headers
|
|
665
|
+
*/
|
|
666
|
+
async handleMetrics(req, res, corsHeaders) {
|
|
667
|
+
if (!this.dataProvider) {
|
|
668
|
+
sendError(res, 503, 'Data provider not available', corsHeaders);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
const data = await this.dataProvider('metrics');
|
|
674
|
+
sendJson(res, 200, {
|
|
675
|
+
timestamp: new Date().toISOString(),
|
|
676
|
+
metrics: data || {},
|
|
677
|
+
}, corsHeaders);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
logger.error(`Metrics error: ${err.message}`);
|
|
680
|
+
sendError(res, 500, 'Failed to fetch metrics', corsHeaders);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Handle sessions endpoint
|
|
686
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
687
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
688
|
+
* @param {Object} corsHeaders - CORS headers
|
|
689
|
+
*/
|
|
690
|
+
async handleSessions(req, res, corsHeaders) {
|
|
691
|
+
if (!this.dataProvider) {
|
|
692
|
+
sendError(res, 503, 'Data provider not available', corsHeaders);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
const data = await this.dataProvider('sessions');
|
|
698
|
+
sendJson(res, 200, {
|
|
699
|
+
timestamp: new Date().toISOString(),
|
|
700
|
+
sessions: data || [],
|
|
701
|
+
count: data?.length || 0,
|
|
702
|
+
}, corsHeaders);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
logger.error(`Sessions error: ${err.message}`);
|
|
705
|
+
sendError(res, 500, 'Failed to fetch sessions', corsHeaders);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Handle agents endpoint
|
|
711
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
712
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
713
|
+
* @param {Object} corsHeaders - CORS headers
|
|
714
|
+
*/
|
|
715
|
+
async handleAgents(req, res, corsHeaders) {
|
|
716
|
+
if (!this.dataProvider) {
|
|
717
|
+
sendError(res, 503, 'Data provider not available', corsHeaders);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
const data = await this.dataProvider('agents');
|
|
723
|
+
sendJson(res, 200, {
|
|
724
|
+
timestamp: new Date().toISOString(),
|
|
725
|
+
agents: data || [],
|
|
726
|
+
count: data?.length || 0,
|
|
727
|
+
}, corsHeaders);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
logger.error(`Agents error: ${err.message}`);
|
|
730
|
+
sendError(res, 500, 'Failed to fetch agents', corsHeaders);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Handle logs endpoint
|
|
736
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
737
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
738
|
+
* @param {Object} corsHeaders - CORS headers
|
|
739
|
+
*/
|
|
740
|
+
async handleLogs(req, res, corsHeaders) {
|
|
741
|
+
if (!this.dataProvider) {
|
|
742
|
+
sendError(res, 503, 'Data provider not available', corsHeaders);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
const data = await this.dataProvider('logs');
|
|
748
|
+
sendJson(res, 200, {
|
|
749
|
+
timestamp: new Date().toISOString(),
|
|
750
|
+
logs: data || [],
|
|
751
|
+
count: data?.length || 0,
|
|
752
|
+
}, corsHeaders);
|
|
753
|
+
} catch (err) {
|
|
754
|
+
logger.error(`Logs error: ${err.message}`);
|
|
755
|
+
sendError(res, 500, 'Failed to fetch logs', corsHeaders);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Handle full status endpoint
|
|
761
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
762
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
763
|
+
* @param {Object} corsHeaders - CORS headers
|
|
764
|
+
*/
|
|
765
|
+
async handleStatus(req, res, corsHeaders) {
|
|
766
|
+
if (!this.dataProvider) {
|
|
767
|
+
sendError(res, 503, 'Data provider not available', corsHeaders);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
const [metrics, sessions, agents, logs] = await Promise.all([
|
|
773
|
+
this.dataProvider('metrics'),
|
|
774
|
+
this.dataProvider('sessions'),
|
|
775
|
+
this.dataProvider('agents'),
|
|
776
|
+
this.dataProvider('logs'),
|
|
777
|
+
]);
|
|
778
|
+
|
|
779
|
+
sendJson(res, 200, {
|
|
780
|
+
timestamp: new Date().toISOString(),
|
|
781
|
+
health: this.getHealth(),
|
|
782
|
+
metrics: metrics || {},
|
|
783
|
+
sessions: sessions || [],
|
|
784
|
+
agents: agents || [],
|
|
785
|
+
logs: logs || [],
|
|
786
|
+
summary: {
|
|
787
|
+
sessionCount: sessions?.length || 0,
|
|
788
|
+
agentCount: agents?.length || 0,
|
|
789
|
+
logCount: logs?.length || 0,
|
|
790
|
+
},
|
|
791
|
+
}, corsHeaders);
|
|
792
|
+
} catch (err) {
|
|
793
|
+
logger.error(`Status error: ${err.message}`);
|
|
794
|
+
sendError(res, 500, 'Failed to fetch status', corsHeaders);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Start the web server
|
|
800
|
+
* @returns {Promise<WebServer>} This instance for chaining
|
|
801
|
+
*/
|
|
802
|
+
async start() {
|
|
803
|
+
return new Promise((resolve, reject) => {
|
|
804
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
805
|
+
|
|
806
|
+
this.server.on('error', (err) => {
|
|
807
|
+
logger.error(`Web server error: ${err.message}`);
|
|
808
|
+
reject(err);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
this.server.listen(this.port, this.host, () => {
|
|
812
|
+
const rateLimitStatus = this.rateLimiter.enabled ? 'enabled' : 'disabled';
|
|
813
|
+
const corsStatus = this.corsManager.allowedOrigins === '*' ? 'allow-all' : 'restricted';
|
|
814
|
+
const authStatus = this.apiKeyAuth.isEnabled() ? 'enabled' : 'disabled';
|
|
815
|
+
logger.info(`Web server listening on http://${this.host}:${this.port} (rate-limit: ${rateLimitStatus}, cors: ${corsStatus}, auth: ${authStatus})`);
|
|
816
|
+
resolve(this);
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Stop the web server
|
|
823
|
+
* @returns {Promise<void>}
|
|
824
|
+
*/
|
|
825
|
+
async stop() {
|
|
826
|
+
if (!this.server) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Stop rate limiter cleanup
|
|
831
|
+
this.rateLimiter.stop();
|
|
832
|
+
|
|
833
|
+
return new Promise((resolve) => {
|
|
834
|
+
this.server.close(() => {
|
|
835
|
+
logger.info('Web server stopped');
|
|
836
|
+
resolve();
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Get server info
|
|
843
|
+
* @returns {Object} Server information
|
|
844
|
+
*/
|
|
845
|
+
getInfo() {
|
|
846
|
+
const rateLimitStatus = this.rateLimiter.getStatus({ headers: {}, socket: {} });
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
host: this.host,
|
|
850
|
+
port: this.port,
|
|
851
|
+
url: `http://${this.host}:${this.port}`,
|
|
852
|
+
endpoints: {
|
|
853
|
+
health: `${WEB.ENDPOINTS.HEALTH}`,
|
|
854
|
+
metrics: `${WEB.ENDPOINTS.METRICS}`,
|
|
855
|
+
sessions: `${WEB.ENDPOINTS.SESSIONS}`,
|
|
856
|
+
agents: `${WEB.ENDPOINTS.AGENTS}`,
|
|
857
|
+
logs: `${WEB.ENDPOINTS.LOGS}`,
|
|
858
|
+
status: `${WEB.ENDPOINTS.STATUS}`,
|
|
859
|
+
},
|
|
860
|
+
uptime: this.formatUptime(Date.now() - this.startTime),
|
|
861
|
+
requests: this.requestCount,
|
|
862
|
+
errors: this.errorCount,
|
|
863
|
+
security: {
|
|
864
|
+
rateLimit: {
|
|
865
|
+
enabled: this.rateLimiter.enabled,
|
|
866
|
+
windowMs: this.rateLimiter.windowMs,
|
|
867
|
+
maxRequests: this.rateLimiter.maxRequests,
|
|
868
|
+
},
|
|
869
|
+
cors: {
|
|
870
|
+
mode: this.corsManager.allowedOrigins === '*' ? 'allow-all' : 'restricted',
|
|
871
|
+
credentials: this.corsManager.credentials,
|
|
872
|
+
},
|
|
873
|
+
auth: {
|
|
874
|
+
enabled: this.apiKeyAuth.isEnabled(),
|
|
875
|
+
scheme: this.apiKeyAuth.scheme,
|
|
876
|
+
headerName: this.apiKeyAuth.headerName,
|
|
877
|
+
activeKeys: this.apiKeyAuth.getKeyCount(),
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export default WebServer;
|
|
885
|
+
export { WebRateLimiter, CorsManager };
|