smartcontext-proxy 0.1.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/PLAN-v2.md +390 -0
- package/dist/src/context/ab-test.d.ts +32 -0
- package/dist/src/context/ab-test.js +133 -0
- package/dist/src/index.js +99 -78
- package/dist/src/proxy/classifier.d.ts +14 -0
- package/dist/src/proxy/classifier.js +63 -0
- package/dist/src/proxy/connect-proxy.d.ts +37 -0
- package/dist/src/proxy/connect-proxy.js +234 -0
- package/dist/src/proxy/server.js +10 -1
- package/dist/src/proxy/tls-interceptor.d.ts +23 -0
- package/dist/src/proxy/tls-interceptor.js +211 -0
- package/dist/src/proxy/transparent-listener.d.ts +31 -0
- package/dist/src/proxy/transparent-listener.js +285 -0
- package/dist/src/proxy/tunnel.d.ts +7 -0
- package/dist/src/proxy/tunnel.js +33 -0
- package/dist/src/system/dns-redirect.d.ts +28 -0
- package/dist/src/system/dns-redirect.js +141 -0
- package/dist/src/system/installer.d.ts +25 -0
- package/dist/src/system/installer.js +180 -0
- package/dist/src/system/linux.d.ts +11 -0
- package/dist/src/system/linux.js +60 -0
- package/dist/src/system/macos.d.ts +24 -0
- package/dist/src/system/macos.js +98 -0
- package/dist/src/system/pf-redirect.d.ts +25 -0
- package/dist/src/system/pf-redirect.js +177 -0
- package/dist/src/system/watchdog.d.ts +7 -0
- package/dist/src/system/watchdog.js +115 -0
- package/dist/src/test/connect-proxy.test.d.ts +1 -0
- package/dist/src/test/connect-proxy.test.js +147 -0
- package/dist/src/test/dashboard.test.js +1 -0
- package/dist/src/tls/ca-manager.d.ts +9 -0
- package/dist/src/tls/ca-manager.js +117 -0
- package/dist/src/tls/trust-store.d.ts +11 -0
- package/dist/src/tls/trust-store.js +121 -0
- package/dist/src/tray/bridge.d.ts +8 -0
- package/dist/src/tray/bridge.js +66 -0
- package/dist/src/ui/dashboard.d.ts +10 -1
- package/dist/src/ui/dashboard.js +119 -34
- package/dist/src/ui/ws-feed.d.ts +8 -0
- package/dist/src/ui/ws-feed.js +30 -0
- package/native/macos/SmartContextTray/Package.swift +13 -0
- package/native/macos/SmartContextTray/Sources/main.swift +206 -0
- package/package.json +6 -2
- package/src/context/ab-test.ts +172 -0
- package/src/index.ts +104 -74
- package/src/proxy/classifier.ts +71 -0
- package/src/proxy/connect-proxy.ts +251 -0
- package/src/proxy/server.ts +11 -2
- package/src/proxy/tls-interceptor.ts +261 -0
- package/src/proxy/transparent-listener.ts +328 -0
- package/src/proxy/tunnel.ts +32 -0
- package/src/system/dns-redirect.ts +144 -0
- package/src/system/installer.ts +148 -0
- package/src/system/linux.ts +57 -0
- package/src/system/macos.ts +89 -0
- package/src/system/pf-redirect.ts +175 -0
- package/src/system/watchdog.ts +76 -0
- package/src/test/connect-proxy.test.ts +170 -0
- package/src/test/dashboard.test.ts +1 -0
- package/src/tls/ca-manager.ts +140 -0
- package/src/tls/trust-store.ts +123 -0
- package/src/tray/bridge.ts +61 -0
- package/src/ui/dashboard.ts +129 -35
- package/src/ui/ws-feed.ts +32 -0
|
@@ -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,32 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a blind TCP tunnel between client and target.
|
|
6
|
+
* Zero overhead — no inspection, no buffering.
|
|
7
|
+
* Used for non-LLM HTTPS traffic.
|
|
8
|
+
*/
|
|
9
|
+
export function createTunnel(clientSocket: Socket, targetHost: string, targetPort: number): void {
|
|
10
|
+
const targetSocket = net.connect(targetPort, targetHost, () => {
|
|
11
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
12
|
+
targetSocket.pipe(clientSocket);
|
|
13
|
+
clientSocket.pipe(targetSocket);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
targetSocket.on('error', (err) => {
|
|
17
|
+
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\n\r\n`);
|
|
18
|
+
clientSocket.end();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
clientSocket.on('error', () => {
|
|
22
|
+
targetSocket.destroy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
clientSocket.on('close', () => {
|
|
26
|
+
targetSocket.destroy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
targetSocket.on('close', () => {
|
|
30
|
+
clientSocket.destroy();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -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,148 @@
|
|
|
1
|
+
import { ensureCA, getCACertPath } from '../tls/ca-manager.js';
|
|
2
|
+
import { installCA, uninstallCA, isCAInstalled } from '../tls/trust-store.js';
|
|
3
|
+
import { installService, uninstallService } from '../daemon/service.js';
|
|
4
|
+
import * as macos from './macos.js';
|
|
5
|
+
import * as linux from './linux.js';
|
|
6
|
+
|
|
7
|
+
const platform = process.platform === 'darwin' ? macos : linux;
|
|
8
|
+
|
|
9
|
+
export interface InstallResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
steps: Array<{ step: string; success: boolean; message: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Full installation:
|
|
16
|
+
* 1. Generate CA cert
|
|
17
|
+
* 2. Install CA in trust store
|
|
18
|
+
* 3. Configure system proxy
|
|
19
|
+
* 4. Install system service
|
|
20
|
+
*/
|
|
21
|
+
export function install(port: number): InstallResult {
|
|
22
|
+
const steps: InstallResult['steps'] = [];
|
|
23
|
+
let allOk = true;
|
|
24
|
+
|
|
25
|
+
// Step 1: Generate CA
|
|
26
|
+
try {
|
|
27
|
+
ensureCA();
|
|
28
|
+
steps.push({ step: 'Generate CA certificate', success: true, message: `CA cert at ${getCACertPath()}` });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
steps.push({ step: 'Generate CA certificate', success: false, message: String(err) });
|
|
31
|
+
allOk = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Step 2: Install CA in trust store
|
|
35
|
+
if (allOk) {
|
|
36
|
+
const result = installCA();
|
|
37
|
+
steps.push({ step: 'Install CA in trust store', success: result.success, message: result.message });
|
|
38
|
+
if (!result.success) allOk = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Step 3: Configure system proxy (PAC URL for selective proxying)
|
|
42
|
+
if (allOk) {
|
|
43
|
+
if (process.platform === 'darwin') {
|
|
44
|
+
const result = macos.setAutoproxyURL(`http://127.0.0.1:${port}/proxy.pac`);
|
|
45
|
+
steps.push({ step: 'Configure system proxy (PAC)', success: result.success, message: result.message });
|
|
46
|
+
if (!result.success) allOk = false;
|
|
47
|
+
} else {
|
|
48
|
+
const result = platform.setSystemProxy('127.0.0.1', port);
|
|
49
|
+
steps.push({ step: 'Configure system proxy', success: result.success, message: result.message });
|
|
50
|
+
if (!result.success) allOk = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Step 4: Install system service
|
|
55
|
+
if (allOk) {
|
|
56
|
+
try {
|
|
57
|
+
const servicePath = installService(port);
|
|
58
|
+
steps.push({ step: 'Install system service', success: true, message: `Service at ${servicePath}` });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
steps.push({ step: 'Install system service', success: false, message: String(err) });
|
|
61
|
+
// Non-fatal: proxy can still run manually
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Rollback on failure
|
|
66
|
+
if (!allOk) {
|
|
67
|
+
rollback(steps);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { success: allOk, steps };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Full uninstallation:
|
|
75
|
+
* 1. Remove system service
|
|
76
|
+
* 2. Clear system proxy
|
|
77
|
+
* 3. Remove CA from trust store
|
|
78
|
+
*/
|
|
79
|
+
export function uninstall(purge: boolean = false): InstallResult {
|
|
80
|
+
const steps: InstallResult['steps'] = [];
|
|
81
|
+
|
|
82
|
+
// Step 1: Remove service
|
|
83
|
+
try {
|
|
84
|
+
const msg = uninstallService();
|
|
85
|
+
steps.push({ step: 'Remove system service', success: true, message: msg });
|
|
86
|
+
} catch (err) {
|
|
87
|
+
steps.push({ step: 'Remove system service', success: false, message: String(err) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Step 2: Clear proxy
|
|
91
|
+
if (process.platform === 'darwin') {
|
|
92
|
+
const r1 = macos.clearAutoproxyURL();
|
|
93
|
+
steps.push({ step: 'Clear auto-proxy', success: r1.success, message: r1.message });
|
|
94
|
+
const r2 = macos.clearSystemProxy();
|
|
95
|
+
steps.push({ step: 'Clear system proxy', success: r2.success, message: r2.message });
|
|
96
|
+
} else {
|
|
97
|
+
const r = linux.clearSystemProxy();
|
|
98
|
+
steps.push({ step: 'Clear system proxy', success: r.success, message: r.message });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Step 3: Remove CA
|
|
102
|
+
const caResult = uninstallCA();
|
|
103
|
+
steps.push({ step: 'Remove CA from trust store', success: caResult.success, message: caResult.message });
|
|
104
|
+
|
|
105
|
+
// Step 4: Purge data (optional)
|
|
106
|
+
if (purge) {
|
|
107
|
+
try {
|
|
108
|
+
const fs = require('node:fs');
|
|
109
|
+
const path = require('node:path');
|
|
110
|
+
const dataDir = path.join(process.env['HOME'] || '.', '.smartcontext');
|
|
111
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
112
|
+
steps.push({ step: 'Purge data directory', success: true, message: `Removed ${dataDir}` });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
steps.push({ step: 'Purge data directory', success: false, message: String(err) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const allOk = steps.every((s) => s.success);
|
|
119
|
+
return { success: allOk, steps };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Check installation status */
|
|
123
|
+
export function status(port: number): Record<string, boolean | string> {
|
|
124
|
+
return {
|
|
125
|
+
caExists: require('node:fs').existsSync(getCACertPath()),
|
|
126
|
+
caInstalled: isCAInstalled(),
|
|
127
|
+
proxyConfigured: platform.isProxyConfigured(port),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Rollback completed steps on failure */
|
|
132
|
+
function rollback(completedSteps: InstallResult['steps']): void {
|
|
133
|
+
for (const step of completedSteps.reverse()) {
|
|
134
|
+
if (!step.success) continue;
|
|
135
|
+
try {
|
|
136
|
+
if (step.step.includes('CA in trust store')) uninstallCA();
|
|
137
|
+
if (step.step.includes('system proxy') || step.step.includes('PAC')) {
|
|
138
|
+
if (process.platform === 'darwin') {
|
|
139
|
+
macos.clearAutoproxyURL();
|
|
140
|
+
macos.clearSystemProxy();
|
|
141
|
+
} else {
|
|
142
|
+
linux.clearSystemProxy();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (step.step.includes('system service')) uninstallService();
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|