@mhingston5/conduit 1.0.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/.env.example +13 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/pr-checks.yml +90 -0
- package/.tool-versions +2 -0
- package/README.md +177 -0
- package/conduit.yaml.test +3 -0
- package/docs/ARCHITECTURE.md +35 -0
- package/docs/CODE_MODE.md +33 -0
- package/docs/SECURITY.md +52 -0
- package/logo.png +0 -0
- package/package.json +74 -0
- package/src/assets/deno-shim.ts +93 -0
- package/src/assets/python-shim.py +21 -0
- package/src/core/asset.utils.ts +42 -0
- package/src/core/concurrency.service.ts +70 -0
- package/src/core/config.service.ts +147 -0
- package/src/core/execution.context.ts +37 -0
- package/src/core/execution.service.ts +209 -0
- package/src/core/interfaces/app.config.ts +17 -0
- package/src/core/interfaces/executor.interface.ts +31 -0
- package/src/core/interfaces/middleware.interface.ts +12 -0
- package/src/core/interfaces/url.validator.interface.ts +3 -0
- package/src/core/logger.ts +64 -0
- package/src/core/metrics.service.ts +112 -0
- package/src/core/middleware/auth.middleware.ts +56 -0
- package/src/core/middleware/error.middleware.ts +21 -0
- package/src/core/middleware/logging.middleware.ts +25 -0
- package/src/core/middleware/middleware.builder.ts +24 -0
- package/src/core/middleware/ratelimit.middleware.ts +31 -0
- package/src/core/network.policy.service.ts +106 -0
- package/src/core/ops.server.ts +74 -0
- package/src/core/otel.service.ts +41 -0
- package/src/core/policy.service.ts +77 -0
- package/src/core/registries/executor.registry.ts +26 -0
- package/src/core/request.controller.ts +297 -0
- package/src/core/security.service.ts +68 -0
- package/src/core/session.manager.ts +44 -0
- package/src/core/types.ts +47 -0
- package/src/executors/deno.executor.ts +342 -0
- package/src/executors/isolate.executor.ts +281 -0
- package/src/executors/pyodide.executor.ts +327 -0
- package/src/executors/pyodide.worker.ts +195 -0
- package/src/gateway/auth.service.ts +104 -0
- package/src/gateway/gateway.service.ts +345 -0
- package/src/gateway/schema.cache.ts +46 -0
- package/src/gateway/upstream.client.ts +244 -0
- package/src/index.ts +92 -0
- package/src/sdk/index.ts +2 -0
- package/src/sdk/sdk-generator.ts +245 -0
- package/src/sdk/tool-binding.ts +86 -0
- package/src/transport/socket.transport.ts +203 -0
- package/tests/__snapshots__/assets.test.ts.snap +97 -0
- package/tests/assets.test.ts +50 -0
- package/tests/auth.service.test.ts +78 -0
- package/tests/code-mode-lite-execution.test.ts +84 -0
- package/tests/code-mode-lite-gateway.test.ts +150 -0
- package/tests/concurrency.service.test.ts +50 -0
- package/tests/concurrency.test.ts +41 -0
- package/tests/config.service.test.ts +70 -0
- package/tests/contract.test.ts +43 -0
- package/tests/deno.executor.test.ts +68 -0
- package/tests/deno_hardening.test.ts +45 -0
- package/tests/dynamic.tool.test.ts +237 -0
- package/tests/e2e_stdio_upstream.test.ts +197 -0
- package/tests/fixtures/stdio-server.ts +42 -0
- package/tests/gateway.manifest.test.ts +82 -0
- package/tests/gateway.service.test.ts +58 -0
- package/tests/gateway.strict.unit.test.ts +74 -0
- package/tests/gateway.validation.unit.test.ts +89 -0
- package/tests/gateway_validation.test.ts +86 -0
- package/tests/hardening.test.ts +139 -0
- package/tests/hardening_v1.test.ts +72 -0
- package/tests/isolate.executor.test.ts +100 -0
- package/tests/log-limit.test.ts +55 -0
- package/tests/middleware.test.ts +106 -0
- package/tests/ops.server.test.ts +65 -0
- package/tests/policy.service.test.ts +90 -0
- package/tests/pyodide.executor.test.ts +101 -0
- package/tests/reference_mcp.ts +40 -0
- package/tests/remediation.test.ts +119 -0
- package/tests/routing.test.ts +148 -0
- package/tests/schema.cache.test.ts +27 -0
- package/tests/sdk/sdk-generator.test.ts +205 -0
- package/tests/socket.transport.test.ts +182 -0
- package/tests/stdio_upstream.test.ts +54 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +22 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { metrics as otelMetrics, Counter, Histogram, ObservableGauge, ValueType } from '@opentelemetry/api';
|
|
2
|
+
|
|
3
|
+
export class MetricsService {
|
|
4
|
+
private static instance: MetricsService;
|
|
5
|
+
private meter = otelMetrics.getMeter('conduit');
|
|
6
|
+
|
|
7
|
+
private executionCounter: Counter;
|
|
8
|
+
private cacheHitsCounter: Counter;
|
|
9
|
+
private cacheMissesCounter: Counter;
|
|
10
|
+
private executionLatency: Histogram;
|
|
11
|
+
private toolExecutionDuration: Histogram;
|
|
12
|
+
private requestQueueLength: ObservableGauge;
|
|
13
|
+
private activeExecutionsGauge: ObservableGauge;
|
|
14
|
+
|
|
15
|
+
private activeExecutionsCount = 0;
|
|
16
|
+
|
|
17
|
+
private queueLengthCallback: () => number = () => 0;
|
|
18
|
+
|
|
19
|
+
private constructor() {
|
|
20
|
+
this.executionCounter = this.meter.createCounter('conduit.executions.total', {
|
|
21
|
+
description: 'Total number of executions',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
this.cacheHitsCounter = this.meter.createCounter('conduit.cache.hits.total', {
|
|
25
|
+
description: 'Total number of schema cache hits',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
this.cacheMissesCounter = this.meter.createCounter('conduit.cache.misses.total', {
|
|
29
|
+
description: 'Total number of schema cache misses',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
this.executionLatency = this.meter.createHistogram('conduit.executions.latency', {
|
|
33
|
+
description: 'Execution latency in milliseconds',
|
|
34
|
+
unit: 'ms',
|
|
35
|
+
valueType: ValueType.DOUBLE,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.toolExecutionDuration = this.meter.createHistogram('conduit.tool.execution_duration_seconds', {
|
|
39
|
+
description: 'Duration of tool executions',
|
|
40
|
+
unit: 's',
|
|
41
|
+
valueType: ValueType.DOUBLE,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.requestQueueLength = this.meter.createObservableGauge('conduit.request_queue_length', {
|
|
45
|
+
description: 'Current request queue depth',
|
|
46
|
+
valueType: ValueType.INT,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.activeExecutionsGauge = this.meter.createObservableGauge('conduit.executions.active', {
|
|
50
|
+
description: 'Current number of active executions',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.activeExecutionsGauge.addCallback((result) => {
|
|
54
|
+
result.observe(this.activeExecutionsCount);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this.requestQueueLength.addCallback((result) => {
|
|
58
|
+
result.observe(this.queueLengthCallback());
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static getInstance(): MetricsService {
|
|
63
|
+
if (!MetricsService.instance) {
|
|
64
|
+
MetricsService.instance = new MetricsService();
|
|
65
|
+
}
|
|
66
|
+
return MetricsService.instance;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
recordExecutionStart() {
|
|
70
|
+
this.activeExecutionsCount++;
|
|
71
|
+
this.executionCounter.add(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
recordExecutionEnd(durationMs: number, toolName?: string) {
|
|
75
|
+
this.activeExecutionsCount = Math.max(0, this.activeExecutionsCount - 1);
|
|
76
|
+
this.executionLatency.record(durationMs, { tool: toolName || 'unknown' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
recordToolExecution(durationMs: number, toolName: string, success: boolean) {
|
|
80
|
+
// Convert ms to seconds for the histogram
|
|
81
|
+
this.toolExecutionDuration.record(durationMs / 1000, {
|
|
82
|
+
tool_name: toolName,
|
|
83
|
+
success: String(success)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
recordCacheHit() {
|
|
88
|
+
this.cacheHitsCounter.add(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
recordCacheMiss() {
|
|
92
|
+
this.cacheMissesCounter.add(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// This is now handled by OTEL Prometheus exporter,
|
|
96
|
+
// but we can provide a way to get the endpoint data if needed.
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
getMetrics() {
|
|
100
|
+
return {
|
|
101
|
+
activeExecutions: this.activeExecutionsCount,
|
|
102
|
+
uptime: process.uptime(),
|
|
103
|
+
memory: process.memoryUsage(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
registerQueueLengthProvider(provider: () => number) {
|
|
108
|
+
this.queueLengthCallback = provider;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const metrics = MetricsService.getInstance();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Middleware, NextFunction } from '../interfaces/middleware.interface.js';
|
|
2
|
+
import { JSONRPCRequest, JSONRPCResponse, ConduitError } from '../types.js';
|
|
3
|
+
import { ExecutionContext } from '../execution.context.js';
|
|
4
|
+
import { SecurityService } from '../security.service.js';
|
|
5
|
+
|
|
6
|
+
export class AuthMiddleware implements Middleware {
|
|
7
|
+
constructor(private securityService: SecurityService) { }
|
|
8
|
+
|
|
9
|
+
async handle(
|
|
10
|
+
request: JSONRPCRequest,
|
|
11
|
+
context: ExecutionContext,
|
|
12
|
+
next: NextFunction
|
|
13
|
+
): Promise<JSONRPCResponse> {
|
|
14
|
+
const providedToken = request.auth?.bearerToken || '';
|
|
15
|
+
|
|
16
|
+
const isMaster = providedToken === this.securityService.getIpcToken();
|
|
17
|
+
const isSession = this.securityService.validateIpcToken(providedToken) && !isMaster;
|
|
18
|
+
|
|
19
|
+
if (!isMaster && !isSession) {
|
|
20
|
+
return {
|
|
21
|
+
jsonrpc: '2.0',
|
|
22
|
+
id: request.id,
|
|
23
|
+
error: {
|
|
24
|
+
code: ConduitError.Forbidden,
|
|
25
|
+
message: 'Invalid bearer token'
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Strict scoping for session tokens
|
|
31
|
+
if (isSession) {
|
|
32
|
+
const allowedMethods = ['mcp.discoverTools', 'mcp.callTool'];
|
|
33
|
+
if (!allowedMethods.includes(request.method)) {
|
|
34
|
+
return {
|
|
35
|
+
jsonrpc: '2.0',
|
|
36
|
+
id: request.id,
|
|
37
|
+
error: {
|
|
38
|
+
code: ConduitError.Forbidden,
|
|
39
|
+
message: 'Session tokens are restricted to tool discovery and calling only'
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Enrich context with session details if needed
|
|
45
|
+
const session = this.securityService.getSession(providedToken);
|
|
46
|
+
if (session?.allowedTools && !context.allowedTools) {
|
|
47
|
+
// If context didn't already have specific tools (e.g. from request params override which shouldn't happen for sessions generally,
|
|
48
|
+
// but usually session allowedTools wins or merges.
|
|
49
|
+
// In generic logic, let's respect session.
|
|
50
|
+
context.allowedTools = session.allowedTools;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return next();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Middleware, NextFunction } from '../interfaces/middleware.interface.js';
|
|
2
|
+
import { JSONRPCRequest, JSONRPCResponse, ConduitError } from '../types.js';
|
|
3
|
+
import { ExecutionContext } from '../execution.context.js';
|
|
4
|
+
|
|
5
|
+
export class ErrorHandlingMiddleware implements Middleware {
|
|
6
|
+
async handle(request: JSONRPCRequest, context: ExecutionContext, next: NextFunction): Promise<JSONRPCResponse> {
|
|
7
|
+
try {
|
|
8
|
+
return await next();
|
|
9
|
+
} catch (err: any) {
|
|
10
|
+
context.logger.error({ err }, 'Error handling request');
|
|
11
|
+
return {
|
|
12
|
+
jsonrpc: '2.0',
|
|
13
|
+
id: request.id,
|
|
14
|
+
error: {
|
|
15
|
+
code: ConduitError.InternalError,
|
|
16
|
+
message: err.message || 'Internal Server Error',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Middleware, NextFunction } from '../interfaces/middleware.interface.js';
|
|
2
|
+
import { JSONRPCRequest, JSONRPCResponse } from '../types.js';
|
|
3
|
+
import { ExecutionContext } from '../execution.context.js';
|
|
4
|
+
import { metrics } from '../metrics.service.js';
|
|
5
|
+
|
|
6
|
+
export class LoggingMiddleware implements Middleware {
|
|
7
|
+
async handle(request: JSONRPCRequest, context: ExecutionContext, next: NextFunction): Promise<JSONRPCResponse> {
|
|
8
|
+
const { method, id } = request;
|
|
9
|
+
const childLogger = context.logger.child({ method, id });
|
|
10
|
+
context.logger = childLogger; // Update context logger for downstream
|
|
11
|
+
|
|
12
|
+
metrics.recordExecutionStart();
|
|
13
|
+
const startTime = Date.now();
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const response = await next();
|
|
17
|
+
metrics.recordExecutionEnd(Date.now() - startTime, method);
|
|
18
|
+
return response;
|
|
19
|
+
} catch (err) {
|
|
20
|
+
// Should be caught by ErrorMiddleware, but just in case
|
|
21
|
+
metrics.recordExecutionEnd(Date.now() - startTime, method);
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MiddlewareBuilder - Factory for building middleware pipelines
|
|
3
|
+
* Extracted from RequestController per architecture-findings.md
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Middleware } from '../interfaces/middleware.interface.js';
|
|
7
|
+
import { ErrorHandlingMiddleware } from './error.middleware.js';
|
|
8
|
+
import { LoggingMiddleware } from './logging.middleware.js';
|
|
9
|
+
import { AuthMiddleware } from './auth.middleware.js';
|
|
10
|
+
import { RateLimitMiddleware } from './ratelimit.middleware.js';
|
|
11
|
+
import { SecurityService } from '../security.service.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the default middleware pipeline used by RequestController.
|
|
15
|
+
* This centralizes middleware configuration outside the controller.
|
|
16
|
+
*/
|
|
17
|
+
export function buildDefaultMiddleware(securityService: SecurityService): Middleware[] {
|
|
18
|
+
return [
|
|
19
|
+
new ErrorHandlingMiddleware(),
|
|
20
|
+
new LoggingMiddleware(),
|
|
21
|
+
new AuthMiddleware(securityService),
|
|
22
|
+
new RateLimitMiddleware(securityService),
|
|
23
|
+
];
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Middleware, NextFunction } from '../interfaces/middleware.interface.js';
|
|
2
|
+
import { JSONRPCRequest, JSONRPCResponse, ConduitError } from '../types.js';
|
|
3
|
+
import { ExecutionContext } from '../execution.context.js';
|
|
4
|
+
import { SecurityService } from '../security.service.js';
|
|
5
|
+
|
|
6
|
+
export class RateLimitMiddleware implements Middleware {
|
|
7
|
+
constructor(private securityService: SecurityService) { }
|
|
8
|
+
|
|
9
|
+
async handle(
|
|
10
|
+
request: JSONRPCRequest,
|
|
11
|
+
context: ExecutionContext,
|
|
12
|
+
next: NextFunction
|
|
13
|
+
): Promise<JSONRPCResponse> {
|
|
14
|
+
const providedToken = request.auth?.bearerToken;
|
|
15
|
+
// Use token if available, otherwise fallback to remote address from context
|
|
16
|
+
const rateLimitKey = providedToken || context.remoteAddress || 'unknown';
|
|
17
|
+
|
|
18
|
+
if (!this.securityService.checkRateLimit(rateLimitKey)) {
|
|
19
|
+
return {
|
|
20
|
+
jsonrpc: '2.0',
|
|
21
|
+
id: request.id,
|
|
22
|
+
error: {
|
|
23
|
+
code: -32005, // Rate limit exceeded code
|
|
24
|
+
message: 'Rate limit exceeded'
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return next();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Logger } from 'pino';
|
|
2
|
+
import dns from 'node:dns/promises';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { LRUCache } from 'lru-cache';
|
|
5
|
+
|
|
6
|
+
export class NetworkPolicyService {
|
|
7
|
+
private logger: Logger;
|
|
8
|
+
|
|
9
|
+
private readonly privateRanges = [
|
|
10
|
+
/^127\./,
|
|
11
|
+
/^10\./,
|
|
12
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
13
|
+
/^192\.168\./,
|
|
14
|
+
/^169\.254\./, // Link-local
|
|
15
|
+
/^localhost$/i,
|
|
16
|
+
/^0\.0\.0\.0$/,
|
|
17
|
+
/^::1$/, // IPv6 localhost
|
|
18
|
+
/^fc00:/i, // IPv6 private
|
|
19
|
+
/^fe80:/i, // IPv6 link-local
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
private readonly RATE_LIMIT = 30;
|
|
23
|
+
private readonly WINDOW_MS = 60000;
|
|
24
|
+
// Use LRUCache to prevent unbounded memory growth
|
|
25
|
+
private requestCounts: LRUCache<string, { count: number; resetTime: number }>;
|
|
26
|
+
|
|
27
|
+
constructor(logger: Logger) {
|
|
28
|
+
this.logger = logger;
|
|
29
|
+
this.requestCounts = new LRUCache({
|
|
30
|
+
max: 10000,
|
|
31
|
+
ttl: this.WINDOW_MS,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async validateUrl(url: string): Promise<{ valid: boolean; message?: string; resolvedIp?: string }> {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
const hostname = parsed.hostname;
|
|
39
|
+
|
|
40
|
+
// Check literal hostname against private ranges
|
|
41
|
+
for (const range of this.privateRanges) {
|
|
42
|
+
if (range.test(hostname)) {
|
|
43
|
+
this.logger.warn({ hostname }, 'SSRF attempt detected: private range access');
|
|
44
|
+
return { valid: false, message: 'Access denied: private network access forbidden' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// DNS resolution check to prevent DNS rebinding and handle tricky hostnames
|
|
49
|
+
if (!net.isIP(hostname)) {
|
|
50
|
+
try {
|
|
51
|
+
const lookup = await dns.lookup(hostname, { all: true });
|
|
52
|
+
// Store resolved IPs to check against blocklist
|
|
53
|
+
const resolvedIps: string[] = [];
|
|
54
|
+
|
|
55
|
+
for (const address of lookup) {
|
|
56
|
+
let ip = address.address;
|
|
57
|
+
|
|
58
|
+
// Fix Sev0: Normalize IPv6-mapped IPv4 addresses
|
|
59
|
+
if (ip.startsWith('::ffff:')) {
|
|
60
|
+
ip = ip.substring(7);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const range of this.privateRanges) {
|
|
64
|
+
if (range.test(ip)) {
|
|
65
|
+
this.logger.warn({ hostname, ip }, 'SSRF attempt detected: DNS resolves to private IP');
|
|
66
|
+
return { valid: false, message: 'Access denied: hostname resolves to private network' };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
resolvedIps.push(ip);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fix Sev1: Return the validated IP to prevent DNS rebinding
|
|
73
|
+
// Use the first resolved IP
|
|
74
|
+
return { valid: true, resolvedIp: resolvedIps[0] };
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
// Strict SSRF protection: block if DNS lookup fails
|
|
77
|
+
this.logger.warn({ hostname, err: err.message }, 'DNS lookup failed during URL validation, blocking request');
|
|
78
|
+
return { valid: false, message: 'Access denied: hostname resolution failed' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// If it was already an IP, it's valid if it passed the range check above
|
|
83
|
+
return { valid: true, resolvedIp: hostname };
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
return { valid: false, message: `Invalid URL: ${err.message}` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
checkRateLimit(key: string): boolean {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const record = this.requestCounts.get(key);
|
|
92
|
+
|
|
93
|
+
if (!record || now > record.resetTime) {
|
|
94
|
+
this.requestCounts.set(key, { count: 1, resetTime: now + this.WINDOW_MS });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (record.count >= this.RATE_LIMIT) {
|
|
99
|
+
this.logger.warn({ key }, 'Rate limit exceeded');
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
record.count++;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { Logger } from 'pino';
|
|
3
|
+
import { AppConfig } from './interfaces/app.config.js';
|
|
4
|
+
import { GatewayService } from '../gateway/gateway.service.js';
|
|
5
|
+
import { metrics } from './metrics.service.js';
|
|
6
|
+
import { RequestController } from './request.controller.js';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
|
|
9
|
+
export class OpsServer {
|
|
10
|
+
private fastify = Fastify();
|
|
11
|
+
private logger: Logger;
|
|
12
|
+
private config: AppConfig;
|
|
13
|
+
private gatewayService: GatewayService;
|
|
14
|
+
private requestController: RequestController;
|
|
15
|
+
|
|
16
|
+
constructor(logger: Logger, config: AppConfig, gatewayService: GatewayService, requestController: RequestController) {
|
|
17
|
+
this.logger = logger;
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.gatewayService = gatewayService;
|
|
20
|
+
this.requestController = requestController;
|
|
21
|
+
|
|
22
|
+
this.setupRoutes();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private setupRoutes() {
|
|
26
|
+
this.fastify.get('/health', async (request, reply) => {
|
|
27
|
+
const gatewayHealth = await this.gatewayService.healthCheck();
|
|
28
|
+
const requestHealth = await this.requestController.healthCheck();
|
|
29
|
+
|
|
30
|
+
const overallStatus = gatewayHealth.status === 'ok' && requestHealth.status === 'ok' ? 'ok' : 'error';
|
|
31
|
+
|
|
32
|
+
return reply.status(overallStatus === 'ok' ? 200 : 503).send({
|
|
33
|
+
status: overallStatus,
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
gateway: gatewayHealth,
|
|
36
|
+
request: requestHealth,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.fastify.get('/metrics', async (request, reply) => {
|
|
41
|
+
try {
|
|
42
|
+
// Proxy from OTEL Prometheus exporter
|
|
43
|
+
// Use ConfigService for metrics URL, default to standard localhost:9464
|
|
44
|
+
const metricsUrl = this.config.metricsUrl || 'http://127.0.0.1:9464/metrics';
|
|
45
|
+
const response = await axios.get(metricsUrl);
|
|
46
|
+
return reply.type('text/plain').send(response.data);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
this.logger.error({ err }, 'Failed to fetch OTEL metrics');
|
|
49
|
+
// Fallback to minimal metrics if OTEL exporter is down
|
|
50
|
+
const fallback = '# Metrics consolidated into OpenTelemetry. Check port 9464.\n' +
|
|
51
|
+
`conduit_uptime_seconds ${process.uptime()}\n` +
|
|
52
|
+
`conduit_memory_rss_bytes ${process.memoryUsage().rss}\n`;
|
|
53
|
+
return reply.type('text/plain').send(fallback);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async listen(): Promise<string> {
|
|
59
|
+
// Use explicit opsPort from config
|
|
60
|
+
const port = this.config.opsPort || 3001;
|
|
61
|
+
try {
|
|
62
|
+
const address = await this.fastify.listen({ port, host: '0.0.0.0' });
|
|
63
|
+
this.logger.info({ address }, 'Ops server listening');
|
|
64
|
+
return address;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
this.logger.error({ err }, 'Failed to start Ops server');
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async close() {
|
|
72
|
+
await this.fastify.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
2
|
+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
|
3
|
+
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
|
|
4
|
+
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
5
|
+
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
|
6
|
+
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
|
|
7
|
+
|
|
8
|
+
export class OtelService {
|
|
9
|
+
private sdk: NodeSDK | null = null;
|
|
10
|
+
|
|
11
|
+
constructor(private logger: any) { }
|
|
12
|
+
|
|
13
|
+
async start() {
|
|
14
|
+
this.sdk = new NodeSDK({
|
|
15
|
+
resource: resourceFromAttributes({
|
|
16
|
+
[SemanticResourceAttributes.SERVICE_NAME]: 'conduit',
|
|
17
|
+
}),
|
|
18
|
+
metricReader: new PrometheusExporter({
|
|
19
|
+
port: 9464, // Default prometheus exporter port
|
|
20
|
+
}),
|
|
21
|
+
instrumentations: [
|
|
22
|
+
getNodeAutoInstrumentations(),
|
|
23
|
+
new PinoInstrumentation(),
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await this.sdk.start();
|
|
29
|
+
this.logger.info('OpenTelemetry SDK started');
|
|
30
|
+
} catch (error) {
|
|
31
|
+
this.logger.error({ error }, 'Error starting OpenTelemetry SDK');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async shutdown() {
|
|
36
|
+
if (this.sdk) {
|
|
37
|
+
await this.sdk.shutdown();
|
|
38
|
+
this.logger.info('OpenTelemetry SDK shut down');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PolicyService - Authorization and tool access control
|
|
3
|
+
* Extracted from GatewayService per architecture-findings.md
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Structured identifier for a tool, replacing fragile `upstream__toolname` strings.
|
|
8
|
+
*/
|
|
9
|
+
export interface ToolIdentifier {
|
|
10
|
+
namespace: string; // upstream ID (e.g., "github")
|
|
11
|
+
name: string; // tool name (e.g., "createIssue")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class PolicyService {
|
|
15
|
+
/**
|
|
16
|
+
* Parse a qualified tool name string into a structured ToolIdentifier.
|
|
17
|
+
* @param qualifiedName - e.g., "github__createIssue" or "github__api__listRepos"
|
|
18
|
+
*/
|
|
19
|
+
parseToolName(qualifiedName: string): ToolIdentifier {
|
|
20
|
+
const separatorIndex = qualifiedName.indexOf('__');
|
|
21
|
+
if (separatorIndex === -1) {
|
|
22
|
+
// No namespace - treat entire string as name with empty namespace
|
|
23
|
+
return { namespace: '', name: qualifiedName };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
namespace: qualifiedName.substring(0, separatorIndex),
|
|
27
|
+
name: qualifiedName.substring(separatorIndex + 2)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Format a ToolIdentifier back to a qualified string.
|
|
33
|
+
*/
|
|
34
|
+
formatToolName(tool: ToolIdentifier): string {
|
|
35
|
+
if (!tool.namespace) {
|
|
36
|
+
return tool.name;
|
|
37
|
+
}
|
|
38
|
+
return `${tool.namespace}__${tool.name}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if a tool matches any pattern in the allowlist.
|
|
43
|
+
* Supports:
|
|
44
|
+
* - Exact match: "github.createIssue" matches "github__createIssue"
|
|
45
|
+
* - Wildcard: "github.*" matches any tool in the github namespace
|
|
46
|
+
*
|
|
47
|
+
* @param tool - ToolIdentifier or qualified string
|
|
48
|
+
* @param allowedTools - Array of patterns (dot-notation, e.g., "github.*" or "github.createIssue")
|
|
49
|
+
*/
|
|
50
|
+
isToolAllowed(tool: ToolIdentifier | string, allowedTools: string[]): boolean {
|
|
51
|
+
const toolId = typeof tool === 'string' ? this.parseToolName(tool) : tool;
|
|
52
|
+
const toolParts = [toolId.namespace, ...toolId.name.split('__')].filter(p => p);
|
|
53
|
+
|
|
54
|
+
return allowedTools.some(pattern => {
|
|
55
|
+
const patternParts = pattern.split('.');
|
|
56
|
+
|
|
57
|
+
// Wildcard pattern: "foo.*" or "foo.bar.*"
|
|
58
|
+
if (patternParts[patternParts.length - 1] === '*') {
|
|
59
|
+
const prefixParts = patternParts.slice(0, -1);
|
|
60
|
+
if (prefixParts.length > toolParts.length) return false;
|
|
61
|
+
|
|
62
|
+
// Check if prefix parts match tool parts exactly
|
|
63
|
+
for (let i = 0; i < prefixParts.length; i++) {
|
|
64
|
+
if (prefixParts[i] !== toolParts[i]) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Exact match: pattern parts must equal tool parts
|
|
70
|
+
if (patternParts.length !== toolParts.length) return false;
|
|
71
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
72
|
+
if (patternParts[i] !== toolParts[i]) return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Executor } from '../interfaces/executor.interface.js';
|
|
2
|
+
|
|
3
|
+
export class ExecutorRegistry {
|
|
4
|
+
private executors = new Map<string, Executor>();
|
|
5
|
+
|
|
6
|
+
register(name: string, executor: Executor): void {
|
|
7
|
+
this.executors.set(name, executor);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get(name: string): Executor | undefined {
|
|
11
|
+
return this.executors.get(name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
has(name: string): boolean {
|
|
15
|
+
return this.executors.has(name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async shutdownAll(): Promise<void> {
|
|
19
|
+
for (const executor of this.executors.values()) {
|
|
20
|
+
if (executor.shutdown) {
|
|
21
|
+
await executor.shutdown();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
this.executors.clear();
|
|
25
|
+
}
|
|
26
|
+
}
|