smartcontext-proxy 0.2.0 → 0.2.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/dist/src/proxy/connect-proxy.d.ts +3 -0
- package/dist/src/proxy/connect-proxy.js +69 -2
- package/dist/src/proxy/server.js +10 -1
- package/dist/src/proxy/transparent-listener.d.ts +31 -0
- package/dist/src/proxy/transparent-listener.js +285 -0
- package/dist/src/system/dns-redirect.d.ts +28 -0
- package/dist/src/system/dns-redirect.js +141 -0
- package/dist/src/system/pf-redirect.d.ts +25 -0
- package/dist/src/system/pf-redirect.js +177 -0
- package/dist/src/test/dashboard.test.js +1 -0
- package/dist/src/ui/dashboard.d.ts +10 -1
- package/dist/src/ui/dashboard.js +119 -34
- package/package.json +1 -1
- package/src/proxy/connect-proxy.ts +67 -3
- package/src/proxy/server.ts +11 -2
- package/src/proxy/transparent-listener.ts +328 -0
- package/src/system/dns-redirect.ts +144 -0
- package/src/system/pf-redirect.ts +175 -0
- package/src/test/dashboard.test.ts +1 -0
- package/src/ui/dashboard.ts +129 -35
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import tls from 'node:tls';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import { getCertForHost } from '../tls/ca-manager.js';
|
|
6
|
+
import { classifyHost } from './classifier.js';
|
|
7
|
+
import { getRealIP } from '../system/dns-redirect.js';
|
|
8
|
+
import type { ProviderAdapter } from '../providers/types.js';
|
|
9
|
+
import type { SmartContextConfig } from '../config/schema.js';
|
|
10
|
+
import { ContextOptimizer } from '../context/optimizer.js';
|
|
11
|
+
import { MetricsCollector } from '../metrics/collector.js';
|
|
12
|
+
import { estimateTokens } from '../context/chunker.js';
|
|
13
|
+
import { getTextContent } from '../context/canonical.js';
|
|
14
|
+
|
|
15
|
+
export interface TransparentOptions {
|
|
16
|
+
config: SmartContextConfig;
|
|
17
|
+
optimizer: ContextOptimizer | null;
|
|
18
|
+
metrics: MetricsCollector;
|
|
19
|
+
adapters: Map<string, ProviderAdapter>;
|
|
20
|
+
paused: boolean;
|
|
21
|
+
requestCounter: { value: number };
|
|
22
|
+
log: (level: string, message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Transparent TLS listener on port 443.
|
|
27
|
+
* When DNS redirects LLM traffic to 127.0.0.1, clients connect directly
|
|
28
|
+
* via TLS (no CONNECT). This server terminates TLS using SNI to pick
|
|
29
|
+
* the right cert, then handles the request.
|
|
30
|
+
*/
|
|
31
|
+
export class TransparentListener {
|
|
32
|
+
private server: net.Server;
|
|
33
|
+
private options: TransparentOptions;
|
|
34
|
+
|
|
35
|
+
constructor(options: TransparentOptions) {
|
|
36
|
+
this.options = options;
|
|
37
|
+
|
|
38
|
+
// Raw TCP server — we need SNI from ClientHello before creating TLS context
|
|
39
|
+
this.server = net.createServer((socket) => this.handleConnection(socket));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async start(port: number = 443, host: string = '127.0.0.1'): Promise<void> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
|
45
|
+
if (err.code === 'EACCES') {
|
|
46
|
+
this.options.log('error', `Port ${port} requires root. Transparent listener disabled.`);
|
|
47
|
+
resolve(); // Non-fatal
|
|
48
|
+
} else {
|
|
49
|
+
reject(err);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
this.server.listen(port, host, () => {
|
|
53
|
+
this.options.log('info', `Transparent TLS listener on ${host}:${port}`);
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async stop(): Promise<void> {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
this.server.close(() => resolve());
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private handleConnection(socket: net.Socket): void {
|
|
66
|
+
// Peek at first bytes to extract SNI from TLS ClientHello
|
|
67
|
+
socket.once('data', (data) => {
|
|
68
|
+
const hostname = extractSNI(data);
|
|
69
|
+
if (!hostname) {
|
|
70
|
+
socket.destroy();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const match = classifyHost(hostname, 443);
|
|
75
|
+
if (!match) {
|
|
76
|
+
// Not an LLM host — shouldn't happen with DNS redirect, but just in case
|
|
77
|
+
socket.destroy();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.options.log('info', `TRANSPARENT ${hostname} (${match.provider})`);
|
|
82
|
+
|
|
83
|
+
const { cert, key } = getCertForHost(hostname);
|
|
84
|
+
|
|
85
|
+
const tlsSocket = new tls.TLSSocket(socket, {
|
|
86
|
+
isServer: true,
|
|
87
|
+
cert,
|
|
88
|
+
key,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Unshift the peeked data back
|
|
92
|
+
tlsSocket.unshift(data);
|
|
93
|
+
// Actually we already consumed data from socket, TLSSocket wraps socket
|
|
94
|
+
// Need to handle this differently — push data through
|
|
95
|
+
|
|
96
|
+
this.handleTLSConnection(tlsSocket, hostname, match);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private handleTLSConnection(
|
|
101
|
+
tlsSocket: tls.TLSSocket,
|
|
102
|
+
hostname: string,
|
|
103
|
+
match: ReturnType<typeof classifyHost> & {},
|
|
104
|
+
): void {
|
|
105
|
+
let requestData = Buffer.alloc(0);
|
|
106
|
+
let headersParsed = false;
|
|
107
|
+
let contentLength = 0;
|
|
108
|
+
let headerEnd = -1;
|
|
109
|
+
|
|
110
|
+
tlsSocket.on('data', (chunk: Buffer) => {
|
|
111
|
+
requestData = Buffer.concat([requestData, chunk]);
|
|
112
|
+
|
|
113
|
+
if (!headersParsed) {
|
|
114
|
+
headerEnd = requestData.indexOf('\r\n\r\n');
|
|
115
|
+
if (headerEnd === -1) return;
|
|
116
|
+
headersParsed = true;
|
|
117
|
+
const headersStr = requestData.subarray(0, headerEnd).toString();
|
|
118
|
+
const clMatch = headersStr.match(/content-length:\s*(\d+)/i);
|
|
119
|
+
contentLength = clMatch ? parseInt(clMatch[1], 10) : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const bodyStart = headerEnd + 4;
|
|
123
|
+
const bodyReceived = requestData.length - bodyStart;
|
|
124
|
+
|
|
125
|
+
if (bodyReceived >= contentLength) {
|
|
126
|
+
tlsSocket.pause();
|
|
127
|
+
this.handleRequest(requestData, hostname, match, tlsSocket).catch((err) => {
|
|
128
|
+
this.options.log('error', `Transparent handler error: ${err}`);
|
|
129
|
+
try { tlsSocket.end(); } catch {}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
tlsSocket.on('error', (err) => {
|
|
135
|
+
this.options.log('error', `TLS error for ${hostname}: ${err.message}`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async handleRequest(
|
|
140
|
+
rawRequest: Buffer,
|
|
141
|
+
hostname: string,
|
|
142
|
+
match: { provider: string },
|
|
143
|
+
clientTLS: tls.TLSSocket,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
this.options.requestCounter.value++;
|
|
146
|
+
const reqId = this.options.requestCounter.value;
|
|
147
|
+
const startTime = Date.now();
|
|
148
|
+
|
|
149
|
+
// Parse HTTP request
|
|
150
|
+
const headerEnd = rawRequest.indexOf('\r\n\r\n');
|
|
151
|
+
const headersStr = rawRequest.subarray(0, headerEnd).toString();
|
|
152
|
+
const body = rawRequest.subarray(headerEnd + 4);
|
|
153
|
+
|
|
154
|
+
const [requestLine, ...headerLines] = headersStr.split('\r\n');
|
|
155
|
+
const [method, reqPath] = requestLine.split(' ');
|
|
156
|
+
|
|
157
|
+
const headers: Record<string, string> = {};
|
|
158
|
+
for (const line of headerLines) {
|
|
159
|
+
const colonIdx = line.indexOf(':');
|
|
160
|
+
if (colonIdx > 0) {
|
|
161
|
+
headers[line.substring(0, colonIdx).toLowerCase().trim()] = line.substring(colonIdx + 1).trim();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Get real IP for this hostname (cached before DNS override)
|
|
166
|
+
const realIP = getRealIP(hostname);
|
|
167
|
+
if (!realIP) {
|
|
168
|
+
this.options.log('error', `No real IP cached for ${hostname}`);
|
|
169
|
+
clientTLS.write('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
|
|
170
|
+
clientTLS.end();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Optimization logic
|
|
175
|
+
const adapter = this.options.adapters.get(match.provider);
|
|
176
|
+
let forwardBody = body;
|
|
177
|
+
let model = 'unknown';
|
|
178
|
+
let originalTokens = 0;
|
|
179
|
+
let optimizedTokens = 0;
|
|
180
|
+
let savingsPercent = 0;
|
|
181
|
+
let passThrough = true;
|
|
182
|
+
|
|
183
|
+
if (adapter && body.length > 0) {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(body.toString());
|
|
186
|
+
model = parsed.model || 'unknown';
|
|
187
|
+
const canonical = adapter.parseRequest(parsed, headers);
|
|
188
|
+
originalTokens = estimateTokens(canonical.systemPrompt || '') +
|
|
189
|
+
canonical.messages.reduce((sum, m) => sum + estimateTokens(getTextContent(m)), 0);
|
|
190
|
+
|
|
191
|
+
if (this.options.optimizer && !this.options.paused) {
|
|
192
|
+
try {
|
|
193
|
+
const result = await this.options.optimizer.optimize(canonical);
|
|
194
|
+
passThrough = result.passThrough;
|
|
195
|
+
if (!result.passThrough) {
|
|
196
|
+
canonical.messages = result.optimizedMessages;
|
|
197
|
+
if (result.systemPrompt !== undefined) canonical.systemPrompt = result.systemPrompt;
|
|
198
|
+
optimizedTokens = result.packed.optimizedTokens;
|
|
199
|
+
savingsPercent = result.packed.savingsPercent;
|
|
200
|
+
forwardBody = Buffer.from(JSON.stringify(adapter.serializeRequest(canonical)));
|
|
201
|
+
} else {
|
|
202
|
+
optimizedTokens = originalTokens;
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
optimizedTokens = originalTokens;
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
optimizedTokens = originalTokens;
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
optimizedTokens = originalTokens;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const latency = Date.now() - startTime;
|
|
216
|
+
const savingsStr = passThrough ? 'pass' : `-${savingsPercent}%`;
|
|
217
|
+
this.options.log('info', `#${reqId} ${match.provider}/${model} ${originalTokens}→${optimizedTokens} ${savingsStr} ${latency}ms [transparent]`);
|
|
218
|
+
|
|
219
|
+
// Forward to real provider IP
|
|
220
|
+
const forwardHeaders = { ...headers };
|
|
221
|
+
forwardHeaders['host'] = hostname;
|
|
222
|
+
forwardHeaders['content-length'] = String(forwardBody.length);
|
|
223
|
+
|
|
224
|
+
const providerRes = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
|
225
|
+
const req = https.request({
|
|
226
|
+
hostname: realIP,
|
|
227
|
+
port: 443,
|
|
228
|
+
path: reqPath,
|
|
229
|
+
method,
|
|
230
|
+
headers: forwardHeaders,
|
|
231
|
+
servername: hostname, // SNI for the real server
|
|
232
|
+
}, resolve);
|
|
233
|
+
req.on('error', reject);
|
|
234
|
+
req.write(forwardBody);
|
|
235
|
+
req.end();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Stream response back
|
|
239
|
+
const resHeaders = [`HTTP/1.1 ${providerRes.statusCode} ${providerRes.statusMessage}`];
|
|
240
|
+
for (const [key, val] of Object.entries(providerRes.headers)) {
|
|
241
|
+
if (val) {
|
|
242
|
+
const values = Array.isArray(val) ? val : [val];
|
|
243
|
+
for (const v of values) resHeaders.push(`${key}: ${v}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
resHeaders.push('', '');
|
|
247
|
+
clientTLS.write(resHeaders.join('\r\n'));
|
|
248
|
+
|
|
249
|
+
await new Promise<void>((resolve) => {
|
|
250
|
+
providerRes.on('data', (chunk) => clientTLS.write(chunk));
|
|
251
|
+
providerRes.on('end', () => { clientTLS.end(); resolve(); });
|
|
252
|
+
providerRes.on('error', () => { clientTLS.end(); resolve(); });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Record metrics
|
|
256
|
+
this.options.metrics.record({
|
|
257
|
+
id: reqId,
|
|
258
|
+
timestamp: Date.now(),
|
|
259
|
+
provider: match.provider,
|
|
260
|
+
model,
|
|
261
|
+
streaming: headers['accept']?.includes('text/event-stream') || false,
|
|
262
|
+
originalTokens,
|
|
263
|
+
optimizedTokens,
|
|
264
|
+
savingsPercent,
|
|
265
|
+
latencyOverheadMs: latency,
|
|
266
|
+
chunksRetrieved: 0,
|
|
267
|
+
topScore: 0,
|
|
268
|
+
passThrough,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Extract SNI hostname from TLS ClientHello.
|
|
275
|
+
* Returns null if not found.
|
|
276
|
+
*/
|
|
277
|
+
function extractSNI(data: Buffer): string | null {
|
|
278
|
+
try {
|
|
279
|
+
// TLS record: type=22 (handshake), version, length
|
|
280
|
+
if (data.length < 5 || data[0] !== 0x16) return null;
|
|
281
|
+
|
|
282
|
+
// Handshake: type=1 (ClientHello)
|
|
283
|
+
let offset = 5;
|
|
284
|
+
if (data[offset] !== 0x01) return null;
|
|
285
|
+
|
|
286
|
+
// Skip handshake length (3 bytes) + client version (2) + random (32)
|
|
287
|
+
offset += 4 + 2 + 32;
|
|
288
|
+
|
|
289
|
+
// Session ID
|
|
290
|
+
const sessionIdLen = data[offset];
|
|
291
|
+
offset += 1 + sessionIdLen;
|
|
292
|
+
|
|
293
|
+
// Cipher suites
|
|
294
|
+
const cipherLen = data.readUInt16BE(offset);
|
|
295
|
+
offset += 2 + cipherLen;
|
|
296
|
+
|
|
297
|
+
// Compression methods
|
|
298
|
+
const compLen = data[offset];
|
|
299
|
+
offset += 1 + compLen;
|
|
300
|
+
|
|
301
|
+
// Extensions
|
|
302
|
+
if (offset + 2 > data.length) return null;
|
|
303
|
+
const extLen = data.readUInt16BE(offset);
|
|
304
|
+
offset += 2;
|
|
305
|
+
|
|
306
|
+
const extEnd = offset + extLen;
|
|
307
|
+
while (offset + 4 < extEnd && offset < data.length) {
|
|
308
|
+
const extType = data.readUInt16BE(offset);
|
|
309
|
+
const extDataLen = data.readUInt16BE(offset + 2);
|
|
310
|
+
offset += 4;
|
|
311
|
+
|
|
312
|
+
if (extType === 0x0000) { // SNI extension
|
|
313
|
+
// Skip SNI list length (2)
|
|
314
|
+
offset += 2;
|
|
315
|
+
const nameType = data[offset];
|
|
316
|
+
offset += 1;
|
|
317
|
+
if (nameType === 0x00) { // hostname
|
|
318
|
+
const nameLen = data.readUInt16BE(offset);
|
|
319
|
+
offset += 2;
|
|
320
|
+
return data.subarray(offset, offset + nameLen).toString('ascii');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
offset += extDataLen;
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import dns from 'node:dns';
|
|
4
|
+
|
|
5
|
+
const HOSTS_FILE = '/etc/hosts';
|
|
6
|
+
const MARKER_START = '# SmartContext Proxy — START (auto-generated, do not edit)';
|
|
7
|
+
const MARKER_END = '# SmartContext Proxy — END';
|
|
8
|
+
|
|
9
|
+
/** LLM provider hostnames to redirect */
|
|
10
|
+
const LLM_HOSTS = [
|
|
11
|
+
'api.anthropic.com',
|
|
12
|
+
'api.openai.com',
|
|
13
|
+
'generativelanguage.googleapis.com',
|
|
14
|
+
'openrouter.ai',
|
|
15
|
+
'api.together.xyz',
|
|
16
|
+
'api.fireworks.ai',
|
|
17
|
+
'api.mistral.ai',
|
|
18
|
+
'api.cohere.com',
|
|
19
|
+
'api.groq.com',
|
|
20
|
+
'api.deepseek.com',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Original IP mappings — needed to forward traffic to real providers */
|
|
24
|
+
const originalIPs = new Map<string, string[]>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve and cache real IPs before overriding DNS.
|
|
28
|
+
* MUST be called before enableDNSRedirect.
|
|
29
|
+
*/
|
|
30
|
+
export async function cacheRealIPs(): Promise<Map<string, string[]>> {
|
|
31
|
+
for (const host of LLM_HOSTS) {
|
|
32
|
+
try {
|
|
33
|
+
const addrs = await new Promise<string[]>((resolve, reject) => {
|
|
34
|
+
dns.resolve4(host, (err, addresses) => {
|
|
35
|
+
if (err) reject(err);
|
|
36
|
+
else resolve(addresses);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
originalIPs.set(host, addrs);
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
return originalIPs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Get cached real IP for a hostname */
|
|
46
|
+
export function getRealIP(hostname: string): string | null {
|
|
47
|
+
const ips = originalIPs.get(hostname);
|
|
48
|
+
return ips?.[0] || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Get all original IP mappings */
|
|
52
|
+
export function getAllRealIPs(): Map<string, string[]> {
|
|
53
|
+
return originalIPs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Add /etc/hosts entries pointing LLM hostnames to 127.0.0.1.
|
|
58
|
+
* Requires sudo.
|
|
59
|
+
* After this, all apps resolve LLM hosts to localhost → our proxy.
|
|
60
|
+
*/
|
|
61
|
+
export function enableDNSRedirect(): { success: boolean; message: string; hosts: number } {
|
|
62
|
+
try {
|
|
63
|
+
const current = fs.readFileSync(HOSTS_FILE, 'utf-8');
|
|
64
|
+
|
|
65
|
+
// Don't add if already present
|
|
66
|
+
if (current.includes(MARKER_START)) {
|
|
67
|
+
return { success: true, message: 'DNS redirect already active', hosts: LLM_HOSTS.length };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const block = [
|
|
71
|
+
'',
|
|
72
|
+
MARKER_START,
|
|
73
|
+
...LLM_HOSTS.map((h) => `127.0.0.1 ${h}`),
|
|
74
|
+
MARKER_END,
|
|
75
|
+
'',
|
|
76
|
+
].join('\n');
|
|
77
|
+
|
|
78
|
+
const newContent = current + block;
|
|
79
|
+
|
|
80
|
+
// Write via sudo tee
|
|
81
|
+
execFileSync('sudo', ['tee', HOSTS_FILE], {
|
|
82
|
+
input: newContent,
|
|
83
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Flush DNS cache
|
|
87
|
+
flushDNS();
|
|
88
|
+
|
|
89
|
+
return { success: true, message: `DNS redirect active: ${LLM_HOSTS.length} hosts → 127.0.0.1`, hosts: LLM_HOSTS.length };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { success: false, message: `Failed: ${err}`, hosts: 0 };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove /etc/hosts entries.
|
|
97
|
+
*/
|
|
98
|
+
export function disableDNSRedirect(): { success: boolean; message: string } {
|
|
99
|
+
try {
|
|
100
|
+
const current = fs.readFileSync(HOSTS_FILE, 'utf-8');
|
|
101
|
+
|
|
102
|
+
if (!current.includes(MARKER_START)) {
|
|
103
|
+
return { success: true, message: 'DNS redirect not active' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Remove our block
|
|
107
|
+
const regex = new RegExp(`\\n?${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n?`, 'g');
|
|
108
|
+
const cleaned = current.replace(regex, '\n');
|
|
109
|
+
|
|
110
|
+
execFileSync('sudo', ['tee', HOSTS_FILE], {
|
|
111
|
+
input: cleaned,
|
|
112
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
flushDNS();
|
|
116
|
+
|
|
117
|
+
return { success: true, message: 'DNS redirect removed' };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { success: false, message: `Failed: ${err}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Check if DNS redirect is active */
|
|
124
|
+
export function isDNSRedirectActive(): boolean {
|
|
125
|
+
try {
|
|
126
|
+
const content = fs.readFileSync(HOSTS_FILE, 'utf-8');
|
|
127
|
+
return content.includes(MARKER_START);
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function flushDNS(): void {
|
|
134
|
+
try {
|
|
135
|
+
if (process.platform === 'darwin') {
|
|
136
|
+
execFileSync('dscacheutil', ['-flushcache'], { stdio: 'pipe' });
|
|
137
|
+
execFileSync('sudo', ['killall', '-HUP', 'mDNSResponder'], { stdio: 'pipe' });
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function escapeRegex(s: string): string {
|
|
143
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
144
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import dns from 'node:dns';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const PF_ANCHOR = 'com.smartcontext';
|
|
7
|
+
const PF_CONF_PATH = path.join(process.env['HOME'] || '.', '.smartcontext', 'pf-smartcontext.conf');
|
|
8
|
+
|
|
9
|
+
/** Known LLM provider hostnames to intercept */
|
|
10
|
+
const LLM_HOSTS = [
|
|
11
|
+
'api.anthropic.com',
|
|
12
|
+
'api.openai.com',
|
|
13
|
+
'generativelanguage.googleapis.com',
|
|
14
|
+
'openrouter.ai',
|
|
15
|
+
'api.together.xyz',
|
|
16
|
+
'api.fireworks.ai',
|
|
17
|
+
'api.mistral.ai',
|
|
18
|
+
'api.cohere.com',
|
|
19
|
+
'api.groq.com',
|
|
20
|
+
'api.deepseek.com',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve hostnames to IPs for pf rules.
|
|
25
|
+
* pf works at IP level, not DNS.
|
|
26
|
+
*/
|
|
27
|
+
async function resolveHosts(): Promise<Map<string, string[]>> {
|
|
28
|
+
const results = new Map<string, string[]>();
|
|
29
|
+
|
|
30
|
+
for (const host of LLM_HOSTS) {
|
|
31
|
+
try {
|
|
32
|
+
const addrs = await new Promise<string[]>((resolve, reject) => {
|
|
33
|
+
dns.resolve4(host, (err, addresses) => {
|
|
34
|
+
if (err) reject(err);
|
|
35
|
+
else resolve(addresses);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
results.set(host, addrs);
|
|
39
|
+
} catch {
|
|
40
|
+
// Host might not resolve — skip
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate pf redirect rules.
|
|
49
|
+
* Redirects outgoing HTTPS (port 443) traffic to LLM provider IPs
|
|
50
|
+
* to our local proxy port.
|
|
51
|
+
*/
|
|
52
|
+
function generatePFConf(hostIPs: Map<string, string[]>, proxyPort: number): string {
|
|
53
|
+
const lines: string[] = [
|
|
54
|
+
`# SmartContext Proxy — auto-generated pf rules`,
|
|
55
|
+
`# Redirects LLM API traffic to local proxy`,
|
|
56
|
+
``,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Collect all IPs into a pf table
|
|
60
|
+
const allIPs: string[] = [];
|
|
61
|
+
for (const [host, ips] of hostIPs) {
|
|
62
|
+
lines.push(`# ${host}: ${ips.join(', ')}`);
|
|
63
|
+
allIPs.push(...ips);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (allIPs.length === 0) {
|
|
67
|
+
lines.push('# No IPs resolved — no rules');
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push(`table <llm_providers> { ${allIPs.join(', ')} }`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
// Redirect outgoing HTTPS to LLM providers → local proxy
|
|
75
|
+
// rdr-to changes destination to localhost:proxyPort
|
|
76
|
+
lines.push(`rdr pass on lo0 proto tcp from any to <llm_providers> port 443 -> 127.0.0.1 port ${proxyPort}`);
|
|
77
|
+
lines.push('');
|
|
78
|
+
// Route the traffic through loopback so rdr applies
|
|
79
|
+
lines.push(`pass out on en0 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
|
|
80
|
+
lines.push(`pass out on en1 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
|
|
81
|
+
lines.push('');
|
|
82
|
+
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Install pf redirect rules.
|
|
88
|
+
* Requires sudo.
|
|
89
|
+
*/
|
|
90
|
+
export async function enablePFRedirect(proxyPort: number): Promise<{ success: boolean; message: string; ips: number }> {
|
|
91
|
+
try {
|
|
92
|
+
const hostIPs = await resolveHosts();
|
|
93
|
+
|
|
94
|
+
let totalIPs = 0;
|
|
95
|
+
for (const ips of hostIPs.values()) totalIPs += ips.length;
|
|
96
|
+
|
|
97
|
+
if (totalIPs === 0) {
|
|
98
|
+
return { success: false, message: 'No LLM provider IPs resolved', ips: 0 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const conf = generatePFConf(hostIPs, proxyPort);
|
|
102
|
+
fs.mkdirSync(path.dirname(PF_CONF_PATH), { recursive: true });
|
|
103
|
+
fs.writeFileSync(PF_CONF_PATH, conf);
|
|
104
|
+
|
|
105
|
+
// Load anchor into pf
|
|
106
|
+
try {
|
|
107
|
+
execFileSync('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { success: false, message: `pfctl load failed: ${err}`, ips: totalIPs };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Enable pf if not already enabled
|
|
113
|
+
try {
|
|
114
|
+
execFileSync('sudo', ['pfctl', '-e'], { stdio: 'pipe' });
|
|
115
|
+
} catch {
|
|
116
|
+
// Already enabled — ok
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { success: true, message: `pf redirect active: ${totalIPs} IPs from ${hostIPs.size} hosts → :${proxyPort}`, ips: totalIPs };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { success: false, message: `Failed: ${err}`, ips: 0 };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remove pf redirect rules.
|
|
127
|
+
*/
|
|
128
|
+
export function disablePFRedirect(): { success: boolean; message: string } {
|
|
129
|
+
try {
|
|
130
|
+
// Flush anchor rules
|
|
131
|
+
try {
|
|
132
|
+
execFileSync('sudo', ['pfctl', '-a', PF_ANCHOR, '-F', 'all'], { stdio: 'pipe' });
|
|
133
|
+
} catch {}
|
|
134
|
+
|
|
135
|
+
// Remove conf file
|
|
136
|
+
try { fs.unlinkSync(PF_CONF_PATH); } catch {}
|
|
137
|
+
|
|
138
|
+
return { success: true, message: 'pf redirect removed' };
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return { success: false, message: `Failed: ${err}` };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if pf redirect is active.
|
|
146
|
+
*/
|
|
147
|
+
export function isPFRedirectActive(): boolean {
|
|
148
|
+
try {
|
|
149
|
+
const result = execFileSync('sudo', ['pfctl', '-a', PF_ANCHOR, '-sr'], {
|
|
150
|
+
encoding: 'utf-8',
|
|
151
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
152
|
+
});
|
|
153
|
+
return result.includes('rdr') || result.includes('route-to');
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Refresh IP addresses (IPs can change due to DNS).
|
|
161
|
+
* Call periodically to keep rules current.
|
|
162
|
+
*/
|
|
163
|
+
export async function refreshPFRules(proxyPort: number): Promise<void> {
|
|
164
|
+
const hostIPs = await resolveHosts();
|
|
165
|
+
let totalIPs = 0;
|
|
166
|
+
for (const ips of hostIPs.values()) totalIPs += ips.length;
|
|
167
|
+
if (totalIPs === 0) return;
|
|
168
|
+
|
|
169
|
+
const conf = generatePFConf(hostIPs, proxyPort);
|
|
170
|
+
fs.writeFileSync(PF_CONF_PATH, conf);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
execFileSync('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
@@ -43,6 +43,7 @@ describe('Dashboard & API', () => {
|
|
|
43
43
|
assert.ok(res.headers['content-type']?.includes('text/html'));
|
|
44
44
|
assert.ok(res.body.includes('SmartContext Proxy'));
|
|
45
45
|
assert.ok(res.body.includes('Total Requests'));
|
|
46
|
+
assert.ok(res.body.includes('Settings'));
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
it('returns status via /_sc/status', async () => {
|