@getaegis/cli 0.8.0 → 0.8.1
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 +5 -0
- package/dist/agent/agent.d.ts +98 -0
- package/dist/agent/agent.d.ts.map +1 -0
- package/dist/agent/agent.js +212 -0
- package/dist/agent/agent.js.map +1 -0
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +2 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/cli/auth.d.ts +19 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +44 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/commands/agent.d.ts +6 -0
- package/dist/cli/commands/agent.d.ts.map +1 -0
- package/dist/cli/commands/agent.js +241 -0
- package/dist/cli/commands/agent.js.map +1 -0
- package/dist/cli/commands/config.d.ts +6 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +125 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/dashboard.d.ts +6 -0
- package/dist/cli/commands/dashboard.d.ts.map +1 -0
- package/dist/cli/commands/dashboard.js +189 -0
- package/dist/cli/commands/dashboard.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +6 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +39 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/gate.d.ts +6 -0
- package/dist/cli/commands/gate.d.ts.map +1 -0
- package/dist/cli/commands/gate.js +196 -0
- package/dist/cli/commands/gate.js.map +1 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +109 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/ledger.d.ts +6 -0
- package/dist/cli/commands/ledger.d.ts.map +1 -0
- package/dist/cli/commands/ledger.js +140 -0
- package/dist/cli/commands/ledger.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +6 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/mcp.js +224 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/policy.d.ts +6 -0
- package/dist/cli/commands/policy.d.ts.map +1 -0
- package/dist/cli/commands/policy.js +126 -0
- package/dist/cli/commands/policy.js.map +1 -0
- package/dist/cli/commands/user.d.ts +6 -0
- package/dist/cli/commands/user.d.ts.map +1 -0
- package/dist/cli/commands/user.js +150 -0
- package/dist/cli/commands/user.js.map +1 -0
- package/dist/cli/commands/vault-manager.d.ts +6 -0
- package/dist/cli/commands/vault-manager.d.ts.map +1 -0
- package/dist/cli/commands/vault-manager.js +240 -0
- package/dist/cli/commands/vault-manager.js.map +1 -0
- package/dist/cli/commands/vault.d.ts +6 -0
- package/dist/cli/commands/vault.d.ts.map +1 -0
- package/dist/cli/commands/vault.js +241 -0
- package/dist/cli/commands/vault.js.map +1 -0
- package/dist/cli/commands/webhook.d.ts +6 -0
- package/dist/cli/commands/webhook.d.ts.map +1 -0
- package/dist/cli/commands/webhook.js +151 -0
- package/dist/cli/commands/webhook.js.map +1 -0
- package/dist/cli/helpers.d.ts +12 -0
- package/dist/cli/helpers.d.ts.map +1 -0
- package/dist/cli/helpers.js +61 -0
- package/dist/cli/helpers.js.map +1 -0
- package/dist/cli/index.d.ts +17 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +17 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/validation.d.ts +37 -0
- package/dist/cli/validation.d.ts.map +1 -0
- package/dist/cli/validation.js +104 -0
- package/dist/cli/validation.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +30 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +108 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +355 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard/dashboard-server.d.ts +95 -0
- package/dist/dashboard/dashboard-server.d.ts.map +1 -0
- package/dist/dashboard/dashboard-server.js +329 -0
- package/dist/dashboard/dashboard-server.js.map +1 -0
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +2 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/dashboard/public/assets/index-CpMruPNh.css +1 -0
- package/dist/dashboard/public/assets/index-DkHiw9_f.js +148 -0
- package/dist/dashboard/public/favicon.svg +6 -0
- package/dist/dashboard/public/index.html +14 -0
- package/dist/db.d.ts +15 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +190 -0
- package/dist/db.js.map +1 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +196 -0
- package/dist/doctor.js.map +1 -0
- package/dist/gate/body-inspector.d.ts +31 -0
- package/dist/gate/body-inspector.d.ts.map +1 -0
- package/dist/gate/body-inspector.js +193 -0
- package/dist/gate/body-inspector.js.map +1 -0
- package/dist/gate/gate.d.ts +168 -0
- package/dist/gate/gate.d.ts.map +1 -0
- package/dist/gate/gate.js +1016 -0
- package/dist/gate/gate.js.map +1 -0
- package/dist/gate/index.d.ts +7 -0
- package/dist/gate/index.d.ts.map +1 -0
- package/dist/gate/index.js +4 -0
- package/dist/gate/index.js.map +1 -0
- package/dist/gate/rate-limiter.d.ts +59 -0
- package/dist/gate/rate-limiter.d.ts.map +1 -0
- package/dist/gate/rate-limiter.js +120 -0
- package/dist/gate/rate-limiter.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger/index.d.ts +3 -0
- package/dist/ledger/index.d.ts.map +1 -0
- package/dist/ledger/index.js +2 -0
- package/dist/ledger/index.js.map +1 -0
- package/dist/ledger/ledger.d.ts +98 -0
- package/dist/ledger/ledger.d.ts.map +1 -0
- package/dist/ledger/ledger.js +145 -0
- package/dist/ledger/ledger.js.map +1 -0
- package/dist/logger/index.d.ts +3 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +2 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/logger.d.ts +58 -0
- package/dist/logger/logger.d.ts.map +1 -0
- package/dist/logger/logger.js +201 -0
- package/dist/logger/logger.js.map +1 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/mcp-server.d.ts +130 -0
- package/dist/mcp/mcp-server.d.ts.map +1 -0
- package/dist/mcp/mcp-server.js +775 -0
- package/dist/mcp/mcp-server.js.map +1 -0
- package/dist/metrics/index.d.ts +3 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +2 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/metrics.d.ts +88 -0
- package/dist/metrics/metrics.d.ts.map +1 -0
- package/dist/metrics/metrics.js +179 -0
- package/dist/metrics/metrics.js.map +1 -0
- package/dist/policy/index.d.ts +3 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +2 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/policy.d.ts +119 -0
- package/dist/policy/policy.d.ts.map +1 -0
- package/dist/policy/policy.js +426 -0
- package/dist/policy/policy.js.map +1 -0
- package/dist/user/index.d.ts +3 -0
- package/dist/user/index.d.ts.map +1 -0
- package/dist/user/index.js +2 -0
- package/dist/user/index.js.map +1 -0
- package/dist/user/user.d.ts +102 -0
- package/dist/user/user.d.ts.map +1 -0
- package/dist/user/user.js +216 -0
- package/dist/user/user.js.map +1 -0
- package/dist/vault/crypto.d.ts +28 -0
- package/dist/vault/crypto.d.ts.map +1 -0
- package/dist/vault/crypto.js +44 -0
- package/dist/vault/crypto.js.map +1 -0
- package/dist/vault/index.d.ts +10 -0
- package/dist/vault/index.d.ts.map +1 -0
- package/dist/vault/index.js +6 -0
- package/dist/vault/index.js.map +1 -0
- package/dist/vault/seal.d.ts +68 -0
- package/dist/vault/seal.d.ts.map +1 -0
- package/dist/vault/seal.js +110 -0
- package/dist/vault/seal.js.map +1 -0
- package/dist/vault/shamir.d.ts +33 -0
- package/dist/vault/shamir.d.ts.map +1 -0
- package/dist/vault/shamir.js +174 -0
- package/dist/vault/shamir.js.map +1 -0
- package/dist/vault/vault-manager.d.ts +62 -0
- package/dist/vault/vault-manager.d.ts.map +1 -0
- package/dist/vault/vault-manager.js +141 -0
- package/dist/vault/vault-manager.js.map +1 -0
- package/dist/vault/vault.d.ts +104 -0
- package/dist/vault/vault.d.ts.map +1 -0
- package/dist/vault/vault.js +259 -0
- package/dist/vault/vault.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +18 -0
- package/dist/version.js.map +1 -0
- package/dist/webhook/index.d.ts +3 -0
- package/dist/webhook/index.d.ts.map +1 -0
- package/dist/webhook/index.js +2 -0
- package/dist/webhook/index.js.map +1 -0
- package/dist/webhook/webhook.d.ts +114 -0
- package/dist/webhook/webhook.d.ts.map +1 -0
- package/dist/webhook/webhook.js +269 -0
- package/dist/webhook/webhook.js.map +1 -0
- package/package.json +7 -3
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import * as https from 'node:https';
|
|
4
|
+
import { createLogger, generateRequestId } from '../logger/index.js';
|
|
5
|
+
import { buildPolicyMap, evaluatePolicy, loadPoliciesFromDirectory } from '../policy/index.js';
|
|
6
|
+
import { VERSION } from '../version.js';
|
|
7
|
+
import { BodyInspector } from './body-inspector.js';
|
|
8
|
+
import { parseRateLimit, RateLimiter } from './rate-limiter.js';
|
|
9
|
+
// ─── Scope → HTTP Method Mapping ─────────────────────────────────────────────
|
|
10
|
+
// Credential scopes restrict which HTTP methods are permitted.
|
|
11
|
+
// "read" → GET, HEAD, OPTIONS (safe/idempotent methods)
|
|
12
|
+
// "write" → POST, PUT, PATCH, DELETE (state-changing methods)
|
|
13
|
+
// "*" → all methods (default)
|
|
14
|
+
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
15
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
16
|
+
/**
|
|
17
|
+
* Check whether an HTTP method is permitted by a credential's scopes.
|
|
18
|
+
* Returns true if the method is allowed, false if blocked.
|
|
19
|
+
*/
|
|
20
|
+
export function methodMatchesScope(method, scopes) {
|
|
21
|
+
if (scopes.includes('*'))
|
|
22
|
+
return true;
|
|
23
|
+
const upper = method.toUpperCase();
|
|
24
|
+
if (scopes.includes('read') && READ_METHODS.has(upper))
|
|
25
|
+
return true;
|
|
26
|
+
if (scopes.includes('write') && WRITE_METHODS.has(upper))
|
|
27
|
+
return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Aegis Gate — HTTP proxy that sits between an AI agent and external APIs.
|
|
32
|
+
*
|
|
33
|
+
* The agent makes requests to: http://localhost:{port}/{service}/actual/api/path
|
|
34
|
+
* Gate resolves the service → looks up credential → injects auth → forwards to real API.
|
|
35
|
+
*
|
|
36
|
+
* The agent NEVER sees the credential.
|
|
37
|
+
*/
|
|
38
|
+
export class Gate {
|
|
39
|
+
server = null;
|
|
40
|
+
vault;
|
|
41
|
+
ledger;
|
|
42
|
+
port;
|
|
43
|
+
logger;
|
|
44
|
+
tlsOptions;
|
|
45
|
+
testUpstream;
|
|
46
|
+
rateLimiter;
|
|
47
|
+
bodyInspector;
|
|
48
|
+
shuttingDown = false;
|
|
49
|
+
activeRequests = 0;
|
|
50
|
+
shutdownTimeoutMs;
|
|
51
|
+
agentRegistry;
|
|
52
|
+
requireAgentAuth;
|
|
53
|
+
policyMap;
|
|
54
|
+
policyMode;
|
|
55
|
+
policyDir;
|
|
56
|
+
policyWatcher;
|
|
57
|
+
metrics;
|
|
58
|
+
webhooks;
|
|
59
|
+
onAuditEntry;
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.vault = options.vault;
|
|
62
|
+
this.ledger = options.ledger;
|
|
63
|
+
this.port = options.port;
|
|
64
|
+
this.logger = createLogger({
|
|
65
|
+
module: 'gate',
|
|
66
|
+
level: options.logLevel ?? 'info',
|
|
67
|
+
});
|
|
68
|
+
this.tlsOptions = options.tls;
|
|
69
|
+
this.testUpstream = options._testUpstream;
|
|
70
|
+
this.rateLimiter = new RateLimiter();
|
|
71
|
+
this.bodyInspector = new BodyInspector();
|
|
72
|
+
this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? 10_000;
|
|
73
|
+
this.agentRegistry = options.agentRegistry;
|
|
74
|
+
this.requireAgentAuth = options.requireAgentAuth ?? false;
|
|
75
|
+
this.policyMode = options.policyMode ?? 'enforce';
|
|
76
|
+
this.policyDir = options.policyDir;
|
|
77
|
+
this.metrics = options.metrics;
|
|
78
|
+
this.webhooks = options.webhooks;
|
|
79
|
+
this.onAuditEntry = options.onAuditEntry;
|
|
80
|
+
// Load policies from disk or test injection
|
|
81
|
+
if (options._testPolicies) {
|
|
82
|
+
this.policyMap = options._testPolicies;
|
|
83
|
+
}
|
|
84
|
+
else if (options.policyDir) {
|
|
85
|
+
this.policyMap = this.loadPolicies(options.policyDir);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
this.policyMap = new Map();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Start the Gate proxy server.
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* Whether the Gate is running with TLS.
|
|
96
|
+
*/
|
|
97
|
+
get isTls() {
|
|
98
|
+
return this.tlsOptions !== undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether policies are loaded and active.
|
|
102
|
+
*/
|
|
103
|
+
get hasPolicies() {
|
|
104
|
+
return this.policyMap.size > 0;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The current policy enforcement mode.
|
|
108
|
+
*/
|
|
109
|
+
get currentPolicyMode() {
|
|
110
|
+
return this.policyMode;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Load policies from a directory.
|
|
114
|
+
*/
|
|
115
|
+
loadPolicies(dir) {
|
|
116
|
+
try {
|
|
117
|
+
const results = loadPoliciesFromDirectory(dir);
|
|
118
|
+
const map = buildPolicyMap(results);
|
|
119
|
+
const valid = results.filter((r) => r.valid).length;
|
|
120
|
+
const invalid = results.filter((r) => !r.valid).length;
|
|
121
|
+
this.logger.info({ dir, valid, invalid }, `Loaded ${valid} policy file(s)`);
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
this.logger.warn({ dir, err }, 'Failed to load policies');
|
|
126
|
+
return new Map();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Reload policies from the configured directory.
|
|
131
|
+
* Called on file system changes for hot-reload.
|
|
132
|
+
*/
|
|
133
|
+
reloadPolicies() {
|
|
134
|
+
if (!this.policyDir)
|
|
135
|
+
return;
|
|
136
|
+
this.policyMap = this.loadPolicies(this.policyDir);
|
|
137
|
+
this.logger.info({ count: this.policyMap.size }, 'Policies reloaded');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Start watching the policy directory for changes (hot-reload).
|
|
141
|
+
* Debounces changes to avoid rapid reloads.
|
|
142
|
+
*/
|
|
143
|
+
startPolicyWatcher() {
|
|
144
|
+
if (!this.policyDir)
|
|
145
|
+
return;
|
|
146
|
+
let debounceTimer = null;
|
|
147
|
+
try {
|
|
148
|
+
this.policyWatcher = fs.watch(this.policyDir, { persistent: false }, () => {
|
|
149
|
+
if (debounceTimer)
|
|
150
|
+
clearTimeout(debounceTimer);
|
|
151
|
+
debounceTimer = setTimeout(() => {
|
|
152
|
+
this.logger.info('Policy files changed — reloading');
|
|
153
|
+
this.reloadPolicies();
|
|
154
|
+
}, 500);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
this.logger.warn({ err }, 'Could not watch policy directory');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
start() {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const handler = (req, res) => {
|
|
164
|
+
this.handleRequest(req, res);
|
|
165
|
+
};
|
|
166
|
+
if (this.tlsOptions) {
|
|
167
|
+
// Validate TLS files before attempting to create the server
|
|
168
|
+
for (const [label, filePath] of [
|
|
169
|
+
['certificate', this.tlsOptions.certPath],
|
|
170
|
+
['private key', this.tlsOptions.keyPath],
|
|
171
|
+
]) {
|
|
172
|
+
if (!fs.existsSync(filePath)) {
|
|
173
|
+
throw new Error(`TLS ${label} file not found: ${filePath}`);
|
|
174
|
+
}
|
|
175
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
176
|
+
const expectedMarker = label === 'certificate' ? '-----BEGIN CERTIFICATE-----' : '-----BEGIN';
|
|
177
|
+
if (!content.includes(expectedMarker)) {
|
|
178
|
+
throw new Error(`TLS ${label} file is not valid PEM format: ${filePath}\n` +
|
|
179
|
+
` Expected a PEM file starting with "${expectedMarker}".\n` +
|
|
180
|
+
` Generate a self-signed cert with:\n` +
|
|
181
|
+
` openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const cert = fs.readFileSync(this.tlsOptions.certPath);
|
|
185
|
+
const key = fs.readFileSync(this.tlsOptions.keyPath);
|
|
186
|
+
this.server = https.createServer({ cert, key }, handler);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
this.server = http.createServer(handler);
|
|
190
|
+
this.logger.warn('Gate is running without TLS — credentials are transmitted in cleartext on localhost');
|
|
191
|
+
}
|
|
192
|
+
// Handle server errors (e.g. EADDRINUSE) before they become unhandled events
|
|
193
|
+
this.server.once('error', (err) => {
|
|
194
|
+
if (err.code === 'EADDRINUSE') {
|
|
195
|
+
reject(new Error(`Port ${this.port} is already in use.\n` +
|
|
196
|
+
` Another process (or another Aegis instance) is using this port.\n` +
|
|
197
|
+
` Either stop that process or use a different port:\n` +
|
|
198
|
+
` aegis gate --port <port>`));
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
reject(err);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
this.server.listen(this.port, () => {
|
|
205
|
+
// Update port in case 0 was passed (OS-assigned)
|
|
206
|
+
const addr = this.server?.address();
|
|
207
|
+
if (addr && typeof addr === 'object') {
|
|
208
|
+
this.port = addr.port;
|
|
209
|
+
}
|
|
210
|
+
const protocol = this.tlsOptions ? 'https' : 'http';
|
|
211
|
+
this.logger.info({ protocol, port: this.port }, `Aegis Gate listening on ${protocol}://localhost:${this.port}`);
|
|
212
|
+
this.logger.info({ port: this.port }, 'Agent requests → localhost:{port}/{service}/path → credential injected → forwarded');
|
|
213
|
+
if (this.policyMap.size > 0) {
|
|
214
|
+
this.logger.info({ count: this.policyMap.size, mode: this.policyMode }, 'Policy engine active');
|
|
215
|
+
this.startPolicyWatcher();
|
|
216
|
+
}
|
|
217
|
+
resolve();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The port the server is listening on (may differ from constructor if 0 was passed).
|
|
223
|
+
*/
|
|
224
|
+
get listeningPort() {
|
|
225
|
+
return this.port;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Stop the Gate proxy server gracefully.
|
|
229
|
+
*
|
|
230
|
+
* 1. Sets `shuttingDown = true` — new requests receive 503 Service Unavailable.
|
|
231
|
+
* 2. Waits for in-flight requests to complete (up to `shutdownTimeoutMs`).
|
|
232
|
+
* 3. Closes the server socket and returns.
|
|
233
|
+
*
|
|
234
|
+
* During the drain phase the server still accepts connections so clients get
|
|
235
|
+
* a clean 503 rather than a connection-refused error.
|
|
236
|
+
*/
|
|
237
|
+
stop() {
|
|
238
|
+
this.shuttingDown = true;
|
|
239
|
+
// Stop watching policy files
|
|
240
|
+
if (this.policyWatcher) {
|
|
241
|
+
this.policyWatcher.close();
|
|
242
|
+
this.policyWatcher = undefined;
|
|
243
|
+
}
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
if (!this.server) {
|
|
246
|
+
resolve({ drained: true, activeAtClose: 0 });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const finish = (drained) => {
|
|
250
|
+
const activeAtClose = this.activeRequests;
|
|
251
|
+
if (!drained) {
|
|
252
|
+
// Force-destroy remaining connections so server.close() can complete
|
|
253
|
+
this.server?.closeAllConnections();
|
|
254
|
+
}
|
|
255
|
+
this.server?.close(() => {
|
|
256
|
+
// shuttingDown stays true — a stopped Gate is permanently shut down.
|
|
257
|
+
// Outbound proxy requests may still error asynchronously; keeping
|
|
258
|
+
// shuttingDown=true ensures error handlers skip Ledger writes.
|
|
259
|
+
this.server = null;
|
|
260
|
+
resolve({ drained, activeAtClose });
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
// If no active requests, shut down immediately
|
|
264
|
+
if (this.activeRequests === 0) {
|
|
265
|
+
this.logger.info('No in-flight requests — shutting down immediately');
|
|
266
|
+
finish(true);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.logger.info({ activeRequests: this.activeRequests }, 'Draining in-flight requests');
|
|
270
|
+
// Poll for active requests to reach 0
|
|
271
|
+
const drainInterval = setInterval(() => {
|
|
272
|
+
if (this.activeRequests === 0) {
|
|
273
|
+
clearInterval(drainInterval);
|
|
274
|
+
clearTimeout(forceTimeout);
|
|
275
|
+
this.logger.info('All in-flight requests drained — shutdown complete');
|
|
276
|
+
finish(true);
|
|
277
|
+
}
|
|
278
|
+
}, 50);
|
|
279
|
+
// Force-close after timeout
|
|
280
|
+
const forceTimeout = setTimeout(() => {
|
|
281
|
+
clearInterval(drainInterval);
|
|
282
|
+
this.logger.warn({ timeoutMs: this.shutdownTimeoutMs, activeRequests: this.activeRequests }, 'Shutdown timeout — forcing close');
|
|
283
|
+
finish(false);
|
|
284
|
+
}, this.shutdownTimeoutMs);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Whether the Gate is currently shutting down (draining in-flight requests).
|
|
289
|
+
*/
|
|
290
|
+
get isShuttingDown() {
|
|
291
|
+
return this.shuttingDown;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The number of currently in-flight requests.
|
|
295
|
+
*/
|
|
296
|
+
get inFlightRequests() {
|
|
297
|
+
return this.activeRequests;
|
|
298
|
+
}
|
|
299
|
+
async handleRequest(req, res) {
|
|
300
|
+
// Reject new requests during shutdown
|
|
301
|
+
if (this.shuttingDown) {
|
|
302
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
303
|
+
res.end(`${JSON.stringify({ error: 'Aegis Gate is shutting down' })}\n`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
this.activeRequests++;
|
|
307
|
+
const decrementActive = () => {
|
|
308
|
+
this.activeRequests = Math.max(0, this.activeRequests - 1);
|
|
309
|
+
};
|
|
310
|
+
// Ensure we decrement when the response finishes (or the connection drops)
|
|
311
|
+
res.on('close', decrementActive);
|
|
312
|
+
// Create a per-request child logger with a correlation ID
|
|
313
|
+
const requestId = generateRequestId();
|
|
314
|
+
const reqLog = this.logger.child({ requestId });
|
|
315
|
+
try {
|
|
316
|
+
const reqUrl = new URL(req.url ?? '/', `http://localhost:${this.port}`);
|
|
317
|
+
const pathParts = reqUrl.pathname.split('/').filter(Boolean);
|
|
318
|
+
// Health check
|
|
319
|
+
if (pathParts[0] === '_aegis' && pathParts[1] === 'health') {
|
|
320
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
321
|
+
res.end(`${JSON.stringify({ status: 'ok', version: VERSION })}\n`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Stats endpoint
|
|
325
|
+
if (pathParts[0] === '_aegis' && pathParts[1] === 'stats') {
|
|
326
|
+
const stats = this.ledger.stats();
|
|
327
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
328
|
+
res.end(`${JSON.stringify(stats)}\n`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Prometheus metrics endpoint
|
|
332
|
+
if (pathParts[0] === '_aegis' && pathParts[1] === 'metrics') {
|
|
333
|
+
if (this.metrics) {
|
|
334
|
+
const metricsOutput = await this.metrics.getMetricsOutput();
|
|
335
|
+
res.writeHead(200, { 'Content-Type': this.metrics.getContentType() });
|
|
336
|
+
res.end(metricsOutput);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
340
|
+
res.end(`${JSON.stringify({ error: 'Metrics not enabled' })}\n`);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// ─── Agent Authentication ──────────────────────────────────────
|
|
345
|
+
// If agent auth is required, validate the X-Aegis-Agent token.
|
|
346
|
+
// The authenticated agent identity flows through to scoping,
|
|
347
|
+
// rate limiting, and audit trail entries.
|
|
348
|
+
let authenticatedAgent;
|
|
349
|
+
if (this.requireAgentAuth && this.agentRegistry) {
|
|
350
|
+
const agentToken = req.headers['x-aegis-agent'];
|
|
351
|
+
if (!agentToken) {
|
|
352
|
+
this.auditBlocked({
|
|
353
|
+
service: pathParts[0] ?? 'unknown',
|
|
354
|
+
targetDomain: 'unknown',
|
|
355
|
+
method: req.method ?? 'GET',
|
|
356
|
+
path: req.url ?? '/',
|
|
357
|
+
reason: 'Missing X-Aegis-Agent header — agent authentication required',
|
|
358
|
+
});
|
|
359
|
+
reqLog.warn({ service: pathParts[0] ?? 'unknown' }, 'Blocked: missing X-Aegis-Agent header');
|
|
360
|
+
this.metrics?.recordBlocked(pathParts[0] ?? 'unknown', 'agent_auth_missing');
|
|
361
|
+
this.webhooks?.emit('agent_auth_failure', {
|
|
362
|
+
service: pathParts[0] ?? 'unknown',
|
|
363
|
+
reason: 'Missing X-Aegis-Agent header',
|
|
364
|
+
method: req.method ?? 'GET',
|
|
365
|
+
path: req.url ?? '/',
|
|
366
|
+
});
|
|
367
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
368
|
+
res.end(`${JSON.stringify({
|
|
369
|
+
error: 'Agent authentication required',
|
|
370
|
+
hint: 'Include X-Aegis-Agent header with your agent token',
|
|
371
|
+
})}\n`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const agent = this.agentRegistry.validateToken(agentToken);
|
|
375
|
+
if (!agent) {
|
|
376
|
+
this.auditBlocked({
|
|
377
|
+
service: pathParts[0] ?? 'unknown',
|
|
378
|
+
targetDomain: 'unknown',
|
|
379
|
+
method: req.method ?? 'GET',
|
|
380
|
+
path: req.url ?? '/',
|
|
381
|
+
reason: 'Invalid agent token in X-Aegis-Agent header',
|
|
382
|
+
});
|
|
383
|
+
reqLog.warn({ service: pathParts[0] ?? 'unknown' }, 'Blocked: invalid agent token');
|
|
384
|
+
this.metrics?.recordBlocked(pathParts[0] ?? 'unknown', 'agent_auth_invalid');
|
|
385
|
+
this.webhooks?.emit('agent_auth_failure', {
|
|
386
|
+
service: pathParts[0] ?? 'unknown',
|
|
387
|
+
reason: 'Invalid agent token',
|
|
388
|
+
method: req.method ?? 'GET',
|
|
389
|
+
path: req.url ?? '/',
|
|
390
|
+
});
|
|
391
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
392
|
+
res.end(`${JSON.stringify({
|
|
393
|
+
error: 'Invalid agent token',
|
|
394
|
+
hint: 'Check your X-Aegis-Agent token or register a new agent with: aegis agent add',
|
|
395
|
+
})}\n`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
authenticatedAgent = agent;
|
|
399
|
+
reqLog.debug({ agent: agent.name, tokenPrefix: agent.tokenPrefix }, 'Authenticated agent');
|
|
400
|
+
}
|
|
401
|
+
else if (this.agentRegistry) {
|
|
402
|
+
// Agent auth not required, but optionally identify the agent if a token is provided
|
|
403
|
+
const agentToken = req.headers['x-aegis-agent'];
|
|
404
|
+
if (agentToken) {
|
|
405
|
+
const agent = this.agentRegistry.validateToken(agentToken);
|
|
406
|
+
if (agent) {
|
|
407
|
+
authenticatedAgent = agent;
|
|
408
|
+
reqLog.debug({ agent: agent.name, tokenPrefix: agent.tokenPrefix }, 'Identified agent');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Route format: /{service}/rest/of/the/path
|
|
413
|
+
const serviceName = pathParts[0];
|
|
414
|
+
if (!serviceName) {
|
|
415
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
416
|
+
res.end(`${JSON.stringify({
|
|
417
|
+
error: 'Missing service name',
|
|
418
|
+
usage: 'GET http://localhost:{port}/{service}/api/path',
|
|
419
|
+
})}\n`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// Look up credential for this service
|
|
423
|
+
const credential = this.vault.getByService(serviceName);
|
|
424
|
+
if (!credential) {
|
|
425
|
+
this.auditBlocked({
|
|
426
|
+
service: serviceName,
|
|
427
|
+
targetDomain: 'unknown',
|
|
428
|
+
method: req.method ?? 'GET',
|
|
429
|
+
path: req.url ?? '/',
|
|
430
|
+
reason: `No credential found for service: ${serviceName}`,
|
|
431
|
+
agentName: authenticatedAgent?.name,
|
|
432
|
+
agentTokenPrefix: authenticatedAgent?.tokenPrefix,
|
|
433
|
+
});
|
|
434
|
+
reqLog.warn({ service: serviceName }, 'Blocked: no credential found');
|
|
435
|
+
this.metrics?.recordBlocked(serviceName, 'no_credential', authenticatedAgent?.name);
|
|
436
|
+
this.webhooks?.emit('blocked_request', {
|
|
437
|
+
service: serviceName,
|
|
438
|
+
reason: 'no_credential',
|
|
439
|
+
method: req.method ?? 'GET',
|
|
440
|
+
path: req.url ?? '/',
|
|
441
|
+
agent: authenticatedAgent?.name,
|
|
442
|
+
});
|
|
443
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
444
|
+
res.end(`${JSON.stringify({
|
|
445
|
+
error: `No credential registered for service: ${serviceName}`,
|
|
446
|
+
hint: `Run: aegis vault add --name ${serviceName} --service ${serviceName} --secret YOUR_KEY --domains api.example.com`,
|
|
447
|
+
})}\n`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// TTL enforcement: reject expired credentials
|
|
451
|
+
if (this.vault.isExpired(credential)) {
|
|
452
|
+
this.auditBlocked({
|
|
453
|
+
service: serviceName,
|
|
454
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
455
|
+
method: req.method ?? 'GET',
|
|
456
|
+
path: req.url ?? '/',
|
|
457
|
+
reason: `Credential "${credential.name}" expired at ${credential.expiresAt}`,
|
|
458
|
+
agentName: authenticatedAgent?.name,
|
|
459
|
+
agentTokenPrefix: authenticatedAgent?.tokenPrefix,
|
|
460
|
+
});
|
|
461
|
+
reqLog.warn({ credential: credential.name, expiredAt: credential.expiresAt }, 'Blocked: credential expired');
|
|
462
|
+
this.metrics?.recordBlocked(serviceName, 'credential_expired', authenticatedAgent?.name);
|
|
463
|
+
this.webhooks?.emit('credential_expiry', {
|
|
464
|
+
service: serviceName,
|
|
465
|
+
credential: credential.name,
|
|
466
|
+
expiredAt: credential.expiresAt,
|
|
467
|
+
agent: authenticatedAgent?.name,
|
|
468
|
+
});
|
|
469
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
470
|
+
res.end(`${JSON.stringify({
|
|
471
|
+
error: 'Credential has expired',
|
|
472
|
+
credential: credential.name,
|
|
473
|
+
expiredAt: credential.expiresAt,
|
|
474
|
+
hint: `Rotate with: aegis vault rotate --name ${credential.name} --secret NEW_SECRET`,
|
|
475
|
+
})}\n`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// ─── Credential Scope Enforcement ────────────────────────────────
|
|
479
|
+
// If the credential has scopes (read/write/*), verify the HTTP
|
|
480
|
+
// method is permitted. read → GET/HEAD/OPTIONS, write → POST/PUT/PATCH/DELETE.
|
|
481
|
+
const reqMethod = req.method ?? 'GET';
|
|
482
|
+
if (!methodMatchesScope(reqMethod, credential.scopes)) {
|
|
483
|
+
const scopeList = credential.scopes.join(', ');
|
|
484
|
+
this.auditBlocked({
|
|
485
|
+
service: serviceName,
|
|
486
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
487
|
+
method: reqMethod,
|
|
488
|
+
path: req.url ?? '/',
|
|
489
|
+
reason: `Method "${reqMethod}" not permitted by credential scopes [${scopeList}]`,
|
|
490
|
+
agentName: authenticatedAgent?.name,
|
|
491
|
+
agentTokenPrefix: authenticatedAgent?.tokenPrefix,
|
|
492
|
+
});
|
|
493
|
+
reqLog.warn({ credential: credential.name, method: reqMethod, scopes: credential.scopes }, 'Blocked: credential scope violation');
|
|
494
|
+
this.metrics?.recordBlocked(serviceName, 'credential_scope', authenticatedAgent?.name);
|
|
495
|
+
this.webhooks?.emit('blocked_request', {
|
|
496
|
+
service: serviceName,
|
|
497
|
+
reason: 'credential_scope',
|
|
498
|
+
credential: credential.name,
|
|
499
|
+
method: reqMethod,
|
|
500
|
+
scopes: credential.scopes,
|
|
501
|
+
agent: authenticatedAgent?.name,
|
|
502
|
+
path: req.url ?? '/',
|
|
503
|
+
});
|
|
504
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
505
|
+
res.end(`${JSON.stringify({
|
|
506
|
+
error: 'Method not permitted by credential scopes',
|
|
507
|
+
method: reqMethod,
|
|
508
|
+
scopes: credential.scopes,
|
|
509
|
+
hint: `Update scopes with: aegis vault update --name ${credential.name} --scopes ${scopeList},${reqMethod === 'GET' ? 'read' : 'write'}`,
|
|
510
|
+
})}\n`);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// ─── Agent Credential Scoping ──────────────────────────────────
|
|
514
|
+
// If agent auth is required, verify this agent has been granted
|
|
515
|
+
// access to the requested credential.
|
|
516
|
+
if (authenticatedAgent && this.requireAgentAuth && this.agentRegistry) {
|
|
517
|
+
if (!this.agentRegistry.hasAccess(authenticatedAgent.id, credential.id)) {
|
|
518
|
+
this.auditBlocked({
|
|
519
|
+
service: serviceName,
|
|
520
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
521
|
+
method: req.method ?? 'GET',
|
|
522
|
+
path: req.url ?? '/',
|
|
523
|
+
reason: `Agent "${authenticatedAgent.name}" not granted access to credential "${credential.name}"`,
|
|
524
|
+
agentName: authenticatedAgent.name,
|
|
525
|
+
agentTokenPrefix: authenticatedAgent.tokenPrefix,
|
|
526
|
+
});
|
|
527
|
+
reqLog.warn({ agent: authenticatedAgent.name, credential: credential.name }, 'Blocked: agent not scoped to credential');
|
|
528
|
+
this.metrics?.recordBlocked(serviceName, 'agent_scope', authenticatedAgent.name);
|
|
529
|
+
this.webhooks?.emit('blocked_request', {
|
|
530
|
+
service: serviceName,
|
|
531
|
+
reason: 'agent_scope',
|
|
532
|
+
agent: authenticatedAgent.name,
|
|
533
|
+
credential: credential.name,
|
|
534
|
+
method: req.method ?? 'GET',
|
|
535
|
+
path: req.url ?? '/',
|
|
536
|
+
});
|
|
537
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
538
|
+
res.end(`${JSON.stringify({
|
|
539
|
+
error: 'Agent not granted access to this credential',
|
|
540
|
+
agent: authenticatedAgent.name,
|
|
541
|
+
credential: credential.name,
|
|
542
|
+
hint: `Grant access with: aegis agent grant --agent ${authenticatedAgent.name} --credential ${credential.name}`,
|
|
543
|
+
})}\n`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// ─── Policy Evaluation ─────────────────────────────────────────
|
|
548
|
+
// If the authenticated agent has a policy, evaluate the request
|
|
549
|
+
// against it. Policy checks: service access, method restrictions,
|
|
550
|
+
// path restrictions, time-of-day windows.
|
|
551
|
+
if (authenticatedAgent && this.policyMap.size > 0) {
|
|
552
|
+
const agentPolicy = this.policyMap.get(authenticatedAgent.name);
|
|
553
|
+
if (agentPolicy) {
|
|
554
|
+
const remainingPathForPolicy = `/${pathParts.slice(1).join('/')}`;
|
|
555
|
+
const evaluation = evaluatePolicy(agentPolicy, {
|
|
556
|
+
service: serviceName,
|
|
557
|
+
method: req.method ?? 'GET',
|
|
558
|
+
path: remainingPathForPolicy,
|
|
559
|
+
});
|
|
560
|
+
if (!evaluation.allowed) {
|
|
561
|
+
const reason = `Policy violation: ${evaluation.reason}`;
|
|
562
|
+
if (this.policyMode === 'enforce') {
|
|
563
|
+
this.auditBlocked({
|
|
564
|
+
service: serviceName,
|
|
565
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
566
|
+
method: req.method ?? 'GET',
|
|
567
|
+
path: req.url ?? '/',
|
|
568
|
+
reason,
|
|
569
|
+
agentName: authenticatedAgent.name,
|
|
570
|
+
agentTokenPrefix: authenticatedAgent.tokenPrefix,
|
|
571
|
+
});
|
|
572
|
+
reqLog.warn({
|
|
573
|
+
agent: authenticatedAgent.name,
|
|
574
|
+
violation: evaluation.violation,
|
|
575
|
+
reason: evaluation.reason,
|
|
576
|
+
}, 'Blocked: policy violation');
|
|
577
|
+
this.metrics?.recordBlocked(serviceName, 'policy_violation', authenticatedAgent.name);
|
|
578
|
+
this.webhooks?.emit('blocked_request', {
|
|
579
|
+
service: serviceName,
|
|
580
|
+
reason: 'policy_violation',
|
|
581
|
+
agent: authenticatedAgent.name,
|
|
582
|
+
violation: evaluation.violation,
|
|
583
|
+
detail: evaluation.reason,
|
|
584
|
+
method: req.method ?? 'GET',
|
|
585
|
+
path: req.url ?? '/',
|
|
586
|
+
});
|
|
587
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
588
|
+
res.end(`${JSON.stringify({
|
|
589
|
+
error: 'Policy violation',
|
|
590
|
+
agent: authenticatedAgent.name,
|
|
591
|
+
violation: evaluation.violation,
|
|
592
|
+
reason: evaluation.reason,
|
|
593
|
+
hint: "Update the agent's policy file to permit this request",
|
|
594
|
+
})}\n`);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// Dry-run mode: log the would-be violation but allow the request through
|
|
598
|
+
this.auditBlocked({
|
|
599
|
+
service: serviceName,
|
|
600
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
601
|
+
method: req.method ?? 'GET',
|
|
602
|
+
path: req.url ?? '/',
|
|
603
|
+
reason: `POLICY_DRY_RUN: ${evaluation.reason}`,
|
|
604
|
+
agentName: authenticatedAgent.name,
|
|
605
|
+
agentTokenPrefix: authenticatedAgent.tokenPrefix,
|
|
606
|
+
});
|
|
607
|
+
reqLog.info({ agent: authenticatedAgent.name, reason: evaluation.reason }, 'Dry-run: would block');
|
|
608
|
+
}
|
|
609
|
+
// ─── Policy Rate Limiting ────────────────────────────────
|
|
610
|
+
// If the matched rule has a rateLimit, enforce it using the
|
|
611
|
+
// shared rate limiter. Keyed per agent+service for isolation.
|
|
612
|
+
if (evaluation.allowed && evaluation.matchedRule?.rateLimit) {
|
|
613
|
+
const policyRateLimitStr = evaluation.matchedRule.rateLimit;
|
|
614
|
+
let policyParsedLimit;
|
|
615
|
+
try {
|
|
616
|
+
policyParsedLimit = parseRateLimit(policyRateLimitStr);
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
reqLog.error({ agent: authenticatedAgent.name, rateLimit: policyRateLimitStr }, 'Invalid policy rate limit config');
|
|
620
|
+
policyParsedLimit = { maxRequests: Number.MAX_SAFE_INTEGER, windowMs: 60_000 };
|
|
621
|
+
}
|
|
622
|
+
const policyRateKey = `policy:${authenticatedAgent.name}:${serviceName}`;
|
|
623
|
+
const policyRateResult = this.rateLimiter.check(policyRateKey, policyParsedLimit);
|
|
624
|
+
if (!policyRateResult.allowed) {
|
|
625
|
+
const reason = `Policy rate limit exceeded: ${policyRateLimitStr} (retry after ${policyRateResult.retryAfterSeconds}s)`;
|
|
626
|
+
if (this.policyMode === 'enforce') {
|
|
627
|
+
this.auditBlocked({
|
|
628
|
+
service: serviceName,
|
|
629
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
630
|
+
method: req.method ?? 'GET',
|
|
631
|
+
path: req.url ?? '/',
|
|
632
|
+
reason,
|
|
633
|
+
agentName: authenticatedAgent.name,
|
|
634
|
+
agentTokenPrefix: authenticatedAgent.tokenPrefix,
|
|
635
|
+
});
|
|
636
|
+
reqLog.warn({
|
|
637
|
+
agent: authenticatedAgent.name,
|
|
638
|
+
limit: policyRateLimitStr,
|
|
639
|
+
retryAfter: policyRateResult.retryAfterSeconds,
|
|
640
|
+
}, 'Blocked: policy rate limit exceeded');
|
|
641
|
+
this.metrics?.recordBlocked(serviceName, 'policy_rate_limit', authenticatedAgent.name);
|
|
642
|
+
this.webhooks?.emit('rate_limit_exceeded', {
|
|
643
|
+
service: serviceName,
|
|
644
|
+
type: 'policy',
|
|
645
|
+
agent: authenticatedAgent.name,
|
|
646
|
+
limit: policyRateLimitStr,
|
|
647
|
+
retryAfter: policyRateResult.retryAfterSeconds,
|
|
648
|
+
});
|
|
649
|
+
res.writeHead(429, {
|
|
650
|
+
'Content-Type': 'application/json',
|
|
651
|
+
'Retry-After': String(policyRateResult.retryAfterSeconds),
|
|
652
|
+
});
|
|
653
|
+
res.end(`${JSON.stringify({
|
|
654
|
+
error: 'Policy rate limit exceeded',
|
|
655
|
+
agent: authenticatedAgent.name,
|
|
656
|
+
limit: policyRateLimitStr,
|
|
657
|
+
retryAfter: policyRateResult.retryAfterSeconds,
|
|
658
|
+
})}\n`);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// Dry-run mode: log but allow through
|
|
662
|
+
this.auditBlocked({
|
|
663
|
+
service: serviceName,
|
|
664
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
665
|
+
method: req.method ?? 'GET',
|
|
666
|
+
path: req.url ?? '/',
|
|
667
|
+
reason: `POLICY_DRY_RUN: ${reason}`,
|
|
668
|
+
agentName: authenticatedAgent.name,
|
|
669
|
+
agentTokenPrefix: authenticatedAgent.tokenPrefix,
|
|
670
|
+
});
|
|
671
|
+
reqLog.info({ agent: authenticatedAgent.name, limit: policyRateLimitStr }, 'Dry-run: would block (policy rate limit)');
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// ─── Per-Agent Rate Limiting ───────────────────────────────────
|
|
677
|
+
// If the authenticated agent has a rate limit, check it before
|
|
678
|
+
// the credential rate limit. More restrictive limit wins.
|
|
679
|
+
if (authenticatedAgent?.rateLimit) {
|
|
680
|
+
let agentParsedLimit;
|
|
681
|
+
try {
|
|
682
|
+
agentParsedLimit = parseRateLimit(authenticatedAgent.rateLimit);
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
reqLog.error({ agent: authenticatedAgent.name, rateLimit: authenticatedAgent.rateLimit }, 'Invalid agent rate limit config');
|
|
686
|
+
agentParsedLimit = { maxRequests: Number.MAX_SAFE_INTEGER, windowMs: 60_000 };
|
|
687
|
+
}
|
|
688
|
+
const agentResult = this.rateLimiter.check(`agent:${authenticatedAgent.id}`, agentParsedLimit);
|
|
689
|
+
if (!agentResult.allowed) {
|
|
690
|
+
this.auditBlocked({
|
|
691
|
+
service: serviceName,
|
|
692
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
693
|
+
method: req.method ?? 'GET',
|
|
694
|
+
path: req.url ?? '/',
|
|
695
|
+
reason: `Agent rate limit exceeded: ${authenticatedAgent.rateLimit} (retry after ${agentResult.retryAfterSeconds}s)`,
|
|
696
|
+
agentName: authenticatedAgent.name,
|
|
697
|
+
agentTokenPrefix: authenticatedAgent.tokenPrefix,
|
|
698
|
+
});
|
|
699
|
+
reqLog.warn({
|
|
700
|
+
agent: authenticatedAgent.name,
|
|
701
|
+
limit: authenticatedAgent.rateLimit,
|
|
702
|
+
retryAfter: agentResult.retryAfterSeconds,
|
|
703
|
+
}, 'Blocked: agent rate limit exceeded');
|
|
704
|
+
this.metrics?.recordBlocked(serviceName, 'agent_rate_limit', authenticatedAgent.name);
|
|
705
|
+
this.webhooks?.emit('rate_limit_exceeded', {
|
|
706
|
+
service: serviceName,
|
|
707
|
+
type: 'agent',
|
|
708
|
+
agent: authenticatedAgent.name,
|
|
709
|
+
limit: authenticatedAgent.rateLimit,
|
|
710
|
+
retryAfter: agentResult.retryAfterSeconds,
|
|
711
|
+
});
|
|
712
|
+
res.writeHead(429, {
|
|
713
|
+
'Content-Type': 'application/json',
|
|
714
|
+
'Retry-After': String(agentResult.retryAfterSeconds),
|
|
715
|
+
});
|
|
716
|
+
res.end(`${JSON.stringify({
|
|
717
|
+
error: 'Agent rate limit exceeded',
|
|
718
|
+
agent: authenticatedAgent.name,
|
|
719
|
+
limit: authenticatedAgent.rateLimit,
|
|
720
|
+
retryAfter: agentResult.retryAfterSeconds,
|
|
721
|
+
})}\n`);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// Rate limit enforcement: check per-credential rate limit
|
|
726
|
+
if (credential.rateLimit) {
|
|
727
|
+
let parsedLimit;
|
|
728
|
+
try {
|
|
729
|
+
parsedLimit = parseRateLimit(credential.rateLimit);
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
reqLog.error({ credential: credential.name, rateLimit: credential.rateLimit }, 'Invalid credential rate limit config');
|
|
733
|
+
parsedLimit = { maxRequests: Number.MAX_SAFE_INTEGER, windowMs: 60_000 };
|
|
734
|
+
}
|
|
735
|
+
const result = this.rateLimiter.check(credential.id, parsedLimit);
|
|
736
|
+
if (!result.allowed) {
|
|
737
|
+
this.auditBlocked({
|
|
738
|
+
service: serviceName,
|
|
739
|
+
targetDomain: credential.domains[0] ?? 'unknown',
|
|
740
|
+
method: req.method ?? 'GET',
|
|
741
|
+
path: req.url ?? '/',
|
|
742
|
+
reason: `Rate limit exceeded: ${credential.rateLimit} (retry after ${result.retryAfterSeconds}s)`,
|
|
743
|
+
agentName: authenticatedAgent?.name,
|
|
744
|
+
agentTokenPrefix: authenticatedAgent?.tokenPrefix,
|
|
745
|
+
});
|
|
746
|
+
reqLog.warn({
|
|
747
|
+
credential: credential.name,
|
|
748
|
+
limit: credential.rateLimit,
|
|
749
|
+
retryAfter: result.retryAfterSeconds,
|
|
750
|
+
}, 'Blocked: credential rate limit exceeded');
|
|
751
|
+
this.metrics?.recordBlocked(serviceName, 'credential_rate_limit', authenticatedAgent?.name);
|
|
752
|
+
this.webhooks?.emit('rate_limit_exceeded', {
|
|
753
|
+
service: serviceName,
|
|
754
|
+
type: 'credential',
|
|
755
|
+
credential: credential.name,
|
|
756
|
+
limit: credential.rateLimit,
|
|
757
|
+
retryAfter: result.retryAfterSeconds,
|
|
758
|
+
agent: authenticatedAgent?.name,
|
|
759
|
+
});
|
|
760
|
+
res.writeHead(429, {
|
|
761
|
+
'Content-Type': 'application/json',
|
|
762
|
+
'Retry-After': String(result.retryAfterSeconds),
|
|
763
|
+
});
|
|
764
|
+
res.end(`${JSON.stringify({
|
|
765
|
+
error: 'Rate limit exceeded',
|
|
766
|
+
credential: credential.name,
|
|
767
|
+
limit: credential.rateLimit,
|
|
768
|
+
retryAfter: result.retryAfterSeconds,
|
|
769
|
+
})}\n`);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Determine target domain:
|
|
774
|
+
// 1. Agent can request a specific domain via X-Target-Host header
|
|
775
|
+
// 2. Otherwise, fall back to the credential's primary (first) domain
|
|
776
|
+
const agentRequestedHost = req.headers['x-target-host'] ?? undefined;
|
|
777
|
+
const targetDomain = agentRequestedHost ?? credential.domains[0];
|
|
778
|
+
const remainingPath = `/${pathParts.slice(1).join('/')}`;
|
|
779
|
+
const query = reqUrl.search ?? '';
|
|
780
|
+
// Domain guard: verify target domain is in the credential's allowlist
|
|
781
|
+
// This is the core security boundary — blocks agents from exfiltrating
|
|
782
|
+
// credentials to domains not explicitly approved.
|
|
783
|
+
if (!this.vault.domainMatches(targetDomain, credential.domains)) {
|
|
784
|
+
this.auditBlocked({
|
|
785
|
+
service: serviceName,
|
|
786
|
+
targetDomain,
|
|
787
|
+
method: req.method ?? 'GET',
|
|
788
|
+
path: remainingPath,
|
|
789
|
+
reason: `Domain "${targetDomain}" not in allowlist [${credential.domains.join(', ')}]`,
|
|
790
|
+
agentName: authenticatedAgent?.name,
|
|
791
|
+
agentTokenPrefix: authenticatedAgent?.tokenPrefix,
|
|
792
|
+
});
|
|
793
|
+
reqLog.warn({ targetDomain, allowed: credential.domains }, 'Blocked: domain guard rejected');
|
|
794
|
+
this.metrics?.recordBlocked(serviceName, 'domain_guard', authenticatedAgent?.name);
|
|
795
|
+
this.webhooks?.emit('blocked_request', {
|
|
796
|
+
service: serviceName,
|
|
797
|
+
reason: 'domain_guard',
|
|
798
|
+
targetDomain,
|
|
799
|
+
allowedDomains: credential.domains,
|
|
800
|
+
agent: authenticatedAgent?.name,
|
|
801
|
+
method: req.method ?? 'GET',
|
|
802
|
+
path: remainingPath,
|
|
803
|
+
});
|
|
804
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
805
|
+
res.end(`${JSON.stringify({
|
|
806
|
+
error: 'Domain not in credential allowlist',
|
|
807
|
+
requested: targetDomain,
|
|
808
|
+
allowed: credential.domains,
|
|
809
|
+
})}\n`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
// Build outbound headers — strip any auth the agent tried to add
|
|
813
|
+
const outboundHeaders = {};
|
|
814
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
815
|
+
const lower = key.toLowerCase();
|
|
816
|
+
// Strip auth headers the agent might have tried to include
|
|
817
|
+
if (lower === 'authorization' || lower === 'x-api-key')
|
|
818
|
+
continue;
|
|
819
|
+
// Don't forward host, target-host override, or agent token
|
|
820
|
+
if (lower === 'host' || lower === 'x-target-host' || lower === 'x-aegis-agent')
|
|
821
|
+
continue;
|
|
822
|
+
outboundHeaders[key] = value;
|
|
823
|
+
}
|
|
824
|
+
// Inject the real credential (query auth may modify the path)
|
|
825
|
+
const injectedPath = this.injectCredential(outboundHeaders, credential, `${remainingPath}${query}`);
|
|
826
|
+
outboundHeaders.host = targetDomain;
|
|
827
|
+
reqLog.debug({ service: serviceName, method: req.method, targetDomain, path: remainingPath }, 'Proxying request');
|
|
828
|
+
// Start request duration timer for Prometheus histogram
|
|
829
|
+
const stopTimer = this.metrics?.startRequestTimer(serviceName);
|
|
830
|
+
// Buffer the request body for inspection before forwarding
|
|
831
|
+
const bodyChunks = [];
|
|
832
|
+
req.on('data', (chunk) => {
|
|
833
|
+
bodyChunks.push(chunk);
|
|
834
|
+
});
|
|
835
|
+
req.on('end', () => {
|
|
836
|
+
const bodyBuffer = Buffer.concat(bodyChunks);
|
|
837
|
+
const bodyString = bodyBuffer.toString('utf-8');
|
|
838
|
+
// Body inspection: scan for credential-like patterns in the request body
|
|
839
|
+
if (credential.bodyInspection !== 'off' && bodyString.length > 0) {
|
|
840
|
+
const inspection = this.bodyInspector.inspect(bodyString);
|
|
841
|
+
if (inspection.suspicious) {
|
|
842
|
+
const matchSummary = inspection.matches.join('; ');
|
|
843
|
+
if (credential.bodyInspection === 'block') {
|
|
844
|
+
this.auditBlocked({
|
|
845
|
+
service: serviceName,
|
|
846
|
+
targetDomain,
|
|
847
|
+
method: req.method ?? 'GET',
|
|
848
|
+
path: remainingPath,
|
|
849
|
+
reason: `Body inspection: potential credential exfiltration — ${matchSummary}`,
|
|
850
|
+
agentName: authenticatedAgent?.name,
|
|
851
|
+
agentTokenPrefix: authenticatedAgent?.tokenPrefix,
|
|
852
|
+
});
|
|
853
|
+
reqLog.warn({ credential: credential.name, matches: inspection.matches }, 'Blocked: body inspection detected exfiltration');
|
|
854
|
+
this.metrics?.recordBlocked(serviceName, 'body_inspection', authenticatedAgent?.name);
|
|
855
|
+
this.webhooks?.emit('body_inspection', {
|
|
856
|
+
service: serviceName,
|
|
857
|
+
credential: credential.name,
|
|
858
|
+
matches: inspection.matches,
|
|
859
|
+
agent: authenticatedAgent?.name,
|
|
860
|
+
method: req.method ?? 'GET',
|
|
861
|
+
path: remainingPath,
|
|
862
|
+
});
|
|
863
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
864
|
+
res.end(`${JSON.stringify({
|
|
865
|
+
error: 'Request body contains credential-like patterns',
|
|
866
|
+
mode: 'block',
|
|
867
|
+
matches: inspection.matches,
|
|
868
|
+
hint: "If this is intentional, set body inspection to 'warn' or 'off' for this credential",
|
|
869
|
+
})}\n`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
// warn mode — log but allow through
|
|
873
|
+
reqLog.warn({ credential: credential.name, matches: inspection.matches }, 'Body inspection: credential-like patterns detected (warn mode)');
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// Forward the request
|
|
877
|
+
const upstream = this.testUpstream;
|
|
878
|
+
const transport = upstream?.protocol === 'http' ? http : https;
|
|
879
|
+
const proxyReq = transport.request({
|
|
880
|
+
hostname: upstream?.hostname ?? targetDomain,
|
|
881
|
+
port: upstream?.port ?? 443,
|
|
882
|
+
path: injectedPath ?? `${remainingPath}${query}`,
|
|
883
|
+
method: req.method,
|
|
884
|
+
headers: outboundHeaders,
|
|
885
|
+
}, (proxyRes) => {
|
|
886
|
+
// Strip any credential info from response headers
|
|
887
|
+
const safeHeaders = { ...proxyRes.headers };
|
|
888
|
+
delete safeHeaders['set-cookie']; // Prevent session hijack via agent
|
|
889
|
+
this.auditAllowed({
|
|
890
|
+
credentialId: credential.id,
|
|
891
|
+
credentialName: credential.name,
|
|
892
|
+
service: serviceName,
|
|
893
|
+
targetDomain,
|
|
894
|
+
method: req.method ?? 'GET',
|
|
895
|
+
path: remainingPath,
|
|
896
|
+
responseCode: proxyRes.statusCode,
|
|
897
|
+
agentName: authenticatedAgent?.name,
|
|
898
|
+
agentTokenPrefix: authenticatedAgent?.tokenPrefix,
|
|
899
|
+
});
|
|
900
|
+
reqLog.info({
|
|
901
|
+
service: serviceName,
|
|
902
|
+
method: req.method,
|
|
903
|
+
path: remainingPath,
|
|
904
|
+
status: proxyRes.statusCode,
|
|
905
|
+
}, 'Request proxied');
|
|
906
|
+
stopTimer?.();
|
|
907
|
+
this.metrics?.recordRequest(serviceName, req.method ?? 'GET', proxyRes.statusCode ?? 500, authenticatedAgent?.name);
|
|
908
|
+
res.writeHead(proxyRes.statusCode ?? 500, safeHeaders);
|
|
909
|
+
proxyRes.pipe(res);
|
|
910
|
+
});
|
|
911
|
+
proxyReq.on('error', (err) => {
|
|
912
|
+
reqLog.error({ service: serviceName, err: err.message }, 'Proxy error');
|
|
913
|
+
if (!this.shuttingDown) {
|
|
914
|
+
try {
|
|
915
|
+
this.auditBlocked({
|
|
916
|
+
service: serviceName,
|
|
917
|
+
targetDomain,
|
|
918
|
+
method: req.method ?? 'GET',
|
|
919
|
+
path: remainingPath,
|
|
920
|
+
reason: `Proxy error: ${err.message}`,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
// Ledger may be unavailable during shutdown cleanup
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (!res.headersSent) {
|
|
928
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
929
|
+
res.end(`${JSON.stringify({ error: 'Failed to reach upstream service' })}\n`);
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
// Write the buffered body and end
|
|
933
|
+
if (bodyBuffer.length > 0) {
|
|
934
|
+
proxyReq.write(bodyBuffer);
|
|
935
|
+
}
|
|
936
|
+
proxyReq.end();
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
catch (err) {
|
|
940
|
+
reqLog.error({ err }, 'Unhandled error');
|
|
941
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
942
|
+
res.end(`${JSON.stringify({ error: 'Internal Aegis Gate error' })}\n`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Inject the credential into outbound request headers based on auth type.
|
|
947
|
+
* For `query` auth, the secret is appended as a URL query parameter instead.
|
|
948
|
+
*/
|
|
949
|
+
injectCredential(headers, credential, path) {
|
|
950
|
+
switch (credential.authType) {
|
|
951
|
+
case 'bearer':
|
|
952
|
+
headers.authorization = `Bearer ${credential.secret}`;
|
|
953
|
+
break;
|
|
954
|
+
case 'header':
|
|
955
|
+
headers[credential.headerName ?? 'x-api-key'] = credential.secret;
|
|
956
|
+
break;
|
|
957
|
+
case 'basic':
|
|
958
|
+
headers.authorization = `Basic ${Buffer.from(credential.secret).toString('base64')}`;
|
|
959
|
+
break;
|
|
960
|
+
case 'query': {
|
|
961
|
+
if (path !== undefined) {
|
|
962
|
+
const paramName = encodeURIComponent(credential.headerName ?? 'key');
|
|
963
|
+
const paramValue = encodeURIComponent(credential.secret);
|
|
964
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
965
|
+
return `${path}${separator}${paramName}=${paramValue}`;
|
|
966
|
+
}
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return path;
|
|
971
|
+
}
|
|
972
|
+
// ─── Audit Wrappers (Ledger + Dashboard Broadcast) ─────────────
|
|
973
|
+
/**
|
|
974
|
+
* Log an allowed request and broadcast to dashboard live feed.
|
|
975
|
+
*/
|
|
976
|
+
auditAllowed(params) {
|
|
977
|
+
this.ledger.logAllowed(params);
|
|
978
|
+
this.onAuditEntry?.({
|
|
979
|
+
timestamp: new Date().toISOString(),
|
|
980
|
+
credentialId: params.credentialId,
|
|
981
|
+
credentialName: params.credentialName,
|
|
982
|
+
service: params.service,
|
|
983
|
+
targetDomain: params.targetDomain,
|
|
984
|
+
method: params.method,
|
|
985
|
+
path: params.path,
|
|
986
|
+
status: 'allowed',
|
|
987
|
+
blockedReason: null,
|
|
988
|
+
responseCode: params.responseCode ?? null,
|
|
989
|
+
agentName: params.agentName ?? null,
|
|
990
|
+
agentTokenPrefix: params.agentTokenPrefix ?? null,
|
|
991
|
+
channel: 'gate',
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Log a blocked request and broadcast to dashboard live feed.
|
|
996
|
+
*/
|
|
997
|
+
auditBlocked(params) {
|
|
998
|
+
this.ledger.logBlocked(params);
|
|
999
|
+
this.onAuditEntry?.({
|
|
1000
|
+
timestamp: new Date().toISOString(),
|
|
1001
|
+
credentialId: null,
|
|
1002
|
+
credentialName: null,
|
|
1003
|
+
service: params.service,
|
|
1004
|
+
targetDomain: params.targetDomain,
|
|
1005
|
+
method: params.method,
|
|
1006
|
+
path: params.path,
|
|
1007
|
+
status: 'blocked',
|
|
1008
|
+
blockedReason: params.reason,
|
|
1009
|
+
responseCode: null,
|
|
1010
|
+
agentName: params.agentName ?? null,
|
|
1011
|
+
agentTokenPrefix: params.agentTokenPrefix ?? null,
|
|
1012
|
+
channel: 'gate',
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
//# sourceMappingURL=gate.js.map
|