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.
@@ -15,6 +15,8 @@ export declare class ConnectProxy {
15
15
  private optimizer;
16
16
  private adapters;
17
17
  private paused;
18
+ private debugHeaders;
19
+ private systemProxyActive;
18
20
  private requestCounter;
19
21
  private config;
20
22
  constructor(config: SmartContextConfig, optimizer?: ContextOptimizer | null, adapters?: Map<string, ProviderAdapter>);
@@ -28,6 +30,7 @@ export declare class ConnectProxy {
28
30
  /** Handle plain HTTP requests (dashboard, API, Ollama interception) */
29
31
  private handleHTTP;
30
32
  private handleAPI;
33
+ private getDashboardState;
31
34
  private generatePAC;
32
35
  private interceptorOptions;
33
36
  private log;
@@ -10,6 +10,9 @@ const tunnel_js_1 = require("./tunnel.js");
10
10
  const tls_interceptor_js_1 = require("./tls-interceptor.js");
11
11
  const collector_js_1 = require("../metrics/collector.js");
12
12
  const dashboard_js_1 = require("../ui/dashboard.js");
13
+ const ab_test_js_1 = require("../context/ab-test.js");
14
+ const trust_store_js_1 = require("../tls/trust-store.js");
15
+ const dns_redirect_js_1 = require("../system/dns-redirect.js");
13
16
  /**
14
17
  * HTTP CONNECT proxy that transparently intercepts LLM traffic.
15
18
  *
@@ -23,6 +26,8 @@ class ConnectProxy {
23
26
  optimizer = null;
24
27
  adapters = new Map();
25
28
  paused = false;
29
+ debugHeaders = false;
30
+ systemProxyActive = false;
26
31
  requestCounter = { value: 0 };
27
32
  config;
28
33
  constructor(config, optimizer, adapters) {
@@ -69,7 +74,7 @@ class ConnectProxy {
69
74
  // Dashboard
70
75
  if (path === '/' && method === 'GET') {
71
76
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
72
- res.end((0, dashboard_js_1.renderDashboard)(this.metrics, this.paused));
77
+ res.end((0, dashboard_js_1.renderDashboard)(this.metrics, this.getDashboardState()));
73
78
  return;
74
79
  }
75
80
  // Health
@@ -99,7 +104,7 @@ class ConnectProxy {
99
104
  res.writeHead(404, { 'Content-Type': 'application/json' });
100
105
  res.end(JSON.stringify({ error: 'Not found' }));
101
106
  }
102
- handleAPI(path, method, req, res) {
107
+ async handleAPI(path, method, req, res) {
103
108
  res.setHeader('Content-Type', 'application/json');
104
109
  switch (path) {
105
110
  case '/_sc/status':
@@ -124,11 +129,73 @@ class ConnectProxy {
124
129
  this.paused = false;
125
130
  res.end(JSON.stringify({ ok: true, state: 'running' }));
126
131
  break;
132
+ case '/_sc/ab-test/enable':
133
+ (0, ab_test_js_1.enableABTest)();
134
+ res.end(JSON.stringify({ ok: true, abTest: true }));
135
+ break;
136
+ case '/_sc/ab-test/disable':
137
+ (0, ab_test_js_1.disableABTest)();
138
+ res.end(JSON.stringify({ ok: true, abTest: false }));
139
+ break;
140
+ case '/_sc/ab-test/results':
141
+ res.end(JSON.stringify((0, ab_test_js_1.getABResults)()));
142
+ break;
143
+ case '/_sc/ab-test/summary':
144
+ res.end(JSON.stringify((0, ab_test_js_1.getABSummary)()));
145
+ break;
146
+ case '/_sc/debug-headers/enable':
147
+ this.debugHeaders = true;
148
+ this.config.logging.debug_headers = true;
149
+ res.end(JSON.stringify({ ok: true, debugHeaders: true }));
150
+ break;
151
+ case '/_sc/debug-headers/disable':
152
+ this.debugHeaders = false;
153
+ this.config.logging.debug_headers = false;
154
+ res.end(JSON.stringify({ ok: true, debugHeaders: false }));
155
+ break;
156
+ case '/_sc/system-proxy/enable':
157
+ try {
158
+ // Cache real IPs before overriding DNS
159
+ await (0, dns_redirect_js_1.cacheRealIPs)();
160
+ const dnsResult = (0, dns_redirect_js_1.enableDNSRedirect)();
161
+ this.systemProxyActive = dnsResult.success;
162
+ res.end(JSON.stringify({ ok: dnsResult.success, message: dnsResult.message, method: 'dns-redirect' }));
163
+ }
164
+ catch (err) {
165
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
166
+ }
167
+ break;
168
+ case '/_sc/system-proxy/disable':
169
+ try {
170
+ const disableResult = (0, dns_redirect_js_1.disableDNSRedirect)();
171
+ this.systemProxyActive = false;
172
+ res.end(JSON.stringify({ ok: disableResult.success, message: disableResult.message }));
173
+ }
174
+ catch (err) {
175
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
176
+ }
177
+ break;
127
178
  default:
128
179
  res.writeHead(404);
129
180
  res.end(JSON.stringify({ error: `Unknown: ${path}` }));
130
181
  }
131
182
  }
183
+ getDashboardState() {
184
+ let caInstalled = false;
185
+ try {
186
+ caInstalled = (0, trust_store_js_1.isCAInstalled)();
187
+ }
188
+ catch { }
189
+ return {
190
+ paused: this.paused,
191
+ mode: this.optimizer ? 'optimizing' : 'transparent',
192
+ proxyType: 'connect',
193
+ abTestEnabled: (0, ab_test_js_1.isABEnabled)(),
194
+ debugHeaders: this.debugHeaders,
195
+ caInstalled,
196
+ systemProxyActive: this.systemProxyActive,
197
+ };
198
+ }
132
199
  generatePAC() {
133
200
  const { getLLMHostnames } = require('./classifier.js');
134
201
  const hosts = getLLMHostnames();
@@ -59,7 +59,16 @@ class ProxyServer {
59
59
  // Dashboard (root path)
60
60
  if (path === '/' && method === 'GET') {
61
61
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
62
- res.end((0, dashboard_js_1.renderDashboard)(this.metrics, this.paused));
62
+ const state = {
63
+ paused: this.paused,
64
+ mode: this.optimizer ? 'optimizing' : 'transparent',
65
+ proxyType: 'legacy',
66
+ abTestEnabled: false,
67
+ debugHeaders: this.config.logging.debug_headers,
68
+ caInstalled: false,
69
+ systemProxyActive: false,
70
+ };
71
+ res.end((0, dashboard_js_1.renderDashboard)(this.metrics, state));
63
72
  return;
64
73
  }
65
74
  // Health check
@@ -0,0 +1,31 @@
1
+ import type { ProviderAdapter } from '../providers/types.js';
2
+ import type { SmartContextConfig } from '../config/schema.js';
3
+ import { ContextOptimizer } from '../context/optimizer.js';
4
+ import { MetricsCollector } from '../metrics/collector.js';
5
+ export interface TransparentOptions {
6
+ config: SmartContextConfig;
7
+ optimizer: ContextOptimizer | null;
8
+ metrics: MetricsCollector;
9
+ adapters: Map<string, ProviderAdapter>;
10
+ paused: boolean;
11
+ requestCounter: {
12
+ value: number;
13
+ };
14
+ log: (level: string, message: string) => void;
15
+ }
16
+ /**
17
+ * Transparent TLS listener on port 443.
18
+ * When DNS redirects LLM traffic to 127.0.0.1, clients connect directly
19
+ * via TLS (no CONNECT). This server terminates TLS using SNI to pick
20
+ * the right cert, then handles the request.
21
+ */
22
+ export declare class TransparentListener {
23
+ private server;
24
+ private options;
25
+ constructor(options: TransparentOptions);
26
+ start(port?: number, host?: string): Promise<void>;
27
+ stop(): Promise<void>;
28
+ private handleConnection;
29
+ private handleTLSConnection;
30
+ private handleRequest;
31
+ }
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TransparentListener = void 0;
7
+ const node_tls_1 = __importDefault(require("node:tls"));
8
+ const node_net_1 = __importDefault(require("node:net"));
9
+ const node_https_1 = __importDefault(require("node:https"));
10
+ const ca_manager_js_1 = require("../tls/ca-manager.js");
11
+ const classifier_js_1 = require("./classifier.js");
12
+ const dns_redirect_js_1 = require("../system/dns-redirect.js");
13
+ const chunker_js_1 = require("../context/chunker.js");
14
+ const canonical_js_1 = require("../context/canonical.js");
15
+ /**
16
+ * Transparent TLS listener on port 443.
17
+ * When DNS redirects LLM traffic to 127.0.0.1, clients connect directly
18
+ * via TLS (no CONNECT). This server terminates TLS using SNI to pick
19
+ * the right cert, then handles the request.
20
+ */
21
+ class TransparentListener {
22
+ server;
23
+ options;
24
+ constructor(options) {
25
+ this.options = options;
26
+ // Raw TCP server — we need SNI from ClientHello before creating TLS context
27
+ this.server = node_net_1.default.createServer((socket) => this.handleConnection(socket));
28
+ }
29
+ async start(port = 443, host = '127.0.0.1') {
30
+ return new Promise((resolve, reject) => {
31
+ this.server.on('error', (err) => {
32
+ if (err.code === 'EACCES') {
33
+ this.options.log('error', `Port ${port} requires root. Transparent listener disabled.`);
34
+ resolve(); // Non-fatal
35
+ }
36
+ else {
37
+ reject(err);
38
+ }
39
+ });
40
+ this.server.listen(port, host, () => {
41
+ this.options.log('info', `Transparent TLS listener on ${host}:${port}`);
42
+ resolve();
43
+ });
44
+ });
45
+ }
46
+ async stop() {
47
+ return new Promise((resolve) => {
48
+ this.server.close(() => resolve());
49
+ });
50
+ }
51
+ handleConnection(socket) {
52
+ // Peek at first bytes to extract SNI from TLS ClientHello
53
+ socket.once('data', (data) => {
54
+ const hostname = extractSNI(data);
55
+ if (!hostname) {
56
+ socket.destroy();
57
+ return;
58
+ }
59
+ const match = (0, classifier_js_1.classifyHost)(hostname, 443);
60
+ if (!match) {
61
+ // Not an LLM host — shouldn't happen with DNS redirect, but just in case
62
+ socket.destroy();
63
+ return;
64
+ }
65
+ this.options.log('info', `TRANSPARENT ${hostname} (${match.provider})`);
66
+ const { cert, key } = (0, ca_manager_js_1.getCertForHost)(hostname);
67
+ const tlsSocket = new node_tls_1.default.TLSSocket(socket, {
68
+ isServer: true,
69
+ cert,
70
+ key,
71
+ });
72
+ // Unshift the peeked data back
73
+ tlsSocket.unshift(data);
74
+ // Actually we already consumed data from socket, TLSSocket wraps socket
75
+ // Need to handle this differently — push data through
76
+ this.handleTLSConnection(tlsSocket, hostname, match);
77
+ });
78
+ }
79
+ handleTLSConnection(tlsSocket, hostname, match) {
80
+ let requestData = Buffer.alloc(0);
81
+ let headersParsed = false;
82
+ let contentLength = 0;
83
+ let headerEnd = -1;
84
+ tlsSocket.on('data', (chunk) => {
85
+ requestData = Buffer.concat([requestData, chunk]);
86
+ if (!headersParsed) {
87
+ headerEnd = requestData.indexOf('\r\n\r\n');
88
+ if (headerEnd === -1)
89
+ return;
90
+ headersParsed = true;
91
+ const headersStr = requestData.subarray(0, headerEnd).toString();
92
+ const clMatch = headersStr.match(/content-length:\s*(\d+)/i);
93
+ contentLength = clMatch ? parseInt(clMatch[1], 10) : 0;
94
+ }
95
+ const bodyStart = headerEnd + 4;
96
+ const bodyReceived = requestData.length - bodyStart;
97
+ if (bodyReceived >= contentLength) {
98
+ tlsSocket.pause();
99
+ this.handleRequest(requestData, hostname, match, tlsSocket).catch((err) => {
100
+ this.options.log('error', `Transparent handler error: ${err}`);
101
+ try {
102
+ tlsSocket.end();
103
+ }
104
+ catch { }
105
+ });
106
+ }
107
+ });
108
+ tlsSocket.on('error', (err) => {
109
+ this.options.log('error', `TLS error for ${hostname}: ${err.message}`);
110
+ });
111
+ }
112
+ async handleRequest(rawRequest, hostname, match, clientTLS) {
113
+ this.options.requestCounter.value++;
114
+ const reqId = this.options.requestCounter.value;
115
+ const startTime = Date.now();
116
+ // Parse HTTP request
117
+ const headerEnd = rawRequest.indexOf('\r\n\r\n');
118
+ const headersStr = rawRequest.subarray(0, headerEnd).toString();
119
+ const body = rawRequest.subarray(headerEnd + 4);
120
+ const [requestLine, ...headerLines] = headersStr.split('\r\n');
121
+ const [method, reqPath] = requestLine.split(' ');
122
+ const headers = {};
123
+ for (const line of headerLines) {
124
+ const colonIdx = line.indexOf(':');
125
+ if (colonIdx > 0) {
126
+ headers[line.substring(0, colonIdx).toLowerCase().trim()] = line.substring(colonIdx + 1).trim();
127
+ }
128
+ }
129
+ // Get real IP for this hostname (cached before DNS override)
130
+ const realIP = (0, dns_redirect_js_1.getRealIP)(hostname);
131
+ if (!realIP) {
132
+ this.options.log('error', `No real IP cached for ${hostname}`);
133
+ clientTLS.write('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
134
+ clientTLS.end();
135
+ return;
136
+ }
137
+ // Optimization logic
138
+ const adapter = this.options.adapters.get(match.provider);
139
+ let forwardBody = body;
140
+ let model = 'unknown';
141
+ let originalTokens = 0;
142
+ let optimizedTokens = 0;
143
+ let savingsPercent = 0;
144
+ let passThrough = true;
145
+ if (adapter && body.length > 0) {
146
+ try {
147
+ const parsed = JSON.parse(body.toString());
148
+ model = parsed.model || 'unknown';
149
+ const canonical = adapter.parseRequest(parsed, headers);
150
+ originalTokens = (0, chunker_js_1.estimateTokens)(canonical.systemPrompt || '') +
151
+ canonical.messages.reduce((sum, m) => sum + (0, chunker_js_1.estimateTokens)((0, canonical_js_1.getTextContent)(m)), 0);
152
+ if (this.options.optimizer && !this.options.paused) {
153
+ try {
154
+ const result = await this.options.optimizer.optimize(canonical);
155
+ passThrough = result.passThrough;
156
+ if (!result.passThrough) {
157
+ canonical.messages = result.optimizedMessages;
158
+ if (result.systemPrompt !== undefined)
159
+ canonical.systemPrompt = result.systemPrompt;
160
+ optimizedTokens = result.packed.optimizedTokens;
161
+ savingsPercent = result.packed.savingsPercent;
162
+ forwardBody = Buffer.from(JSON.stringify(adapter.serializeRequest(canonical)));
163
+ }
164
+ else {
165
+ optimizedTokens = originalTokens;
166
+ }
167
+ }
168
+ catch {
169
+ optimizedTokens = originalTokens;
170
+ }
171
+ }
172
+ else {
173
+ optimizedTokens = originalTokens;
174
+ }
175
+ }
176
+ catch {
177
+ optimizedTokens = originalTokens;
178
+ }
179
+ }
180
+ const latency = Date.now() - startTime;
181
+ const savingsStr = passThrough ? 'pass' : `-${savingsPercent}%`;
182
+ this.options.log('info', `#${reqId} ${match.provider}/${model} ${originalTokens}→${optimizedTokens} ${savingsStr} ${latency}ms [transparent]`);
183
+ // Forward to real provider IP
184
+ const forwardHeaders = { ...headers };
185
+ forwardHeaders['host'] = hostname;
186
+ forwardHeaders['content-length'] = String(forwardBody.length);
187
+ const providerRes = await new Promise((resolve, reject) => {
188
+ const req = node_https_1.default.request({
189
+ hostname: realIP,
190
+ port: 443,
191
+ path: reqPath,
192
+ method,
193
+ headers: forwardHeaders,
194
+ servername: hostname, // SNI for the real server
195
+ }, resolve);
196
+ req.on('error', reject);
197
+ req.write(forwardBody);
198
+ req.end();
199
+ });
200
+ // Stream response back
201
+ const resHeaders = [`HTTP/1.1 ${providerRes.statusCode} ${providerRes.statusMessage}`];
202
+ for (const [key, val] of Object.entries(providerRes.headers)) {
203
+ if (val) {
204
+ const values = Array.isArray(val) ? val : [val];
205
+ for (const v of values)
206
+ resHeaders.push(`${key}: ${v}`);
207
+ }
208
+ }
209
+ resHeaders.push('', '');
210
+ clientTLS.write(resHeaders.join('\r\n'));
211
+ await new Promise((resolve) => {
212
+ providerRes.on('data', (chunk) => clientTLS.write(chunk));
213
+ providerRes.on('end', () => { clientTLS.end(); resolve(); });
214
+ providerRes.on('error', () => { clientTLS.end(); resolve(); });
215
+ });
216
+ // Record metrics
217
+ this.options.metrics.record({
218
+ id: reqId,
219
+ timestamp: Date.now(),
220
+ provider: match.provider,
221
+ model,
222
+ streaming: headers['accept']?.includes('text/event-stream') || false,
223
+ originalTokens,
224
+ optimizedTokens,
225
+ savingsPercent,
226
+ latencyOverheadMs: latency,
227
+ chunksRetrieved: 0,
228
+ topScore: 0,
229
+ passThrough,
230
+ });
231
+ }
232
+ }
233
+ exports.TransparentListener = TransparentListener;
234
+ /**
235
+ * Extract SNI hostname from TLS ClientHello.
236
+ * Returns null if not found.
237
+ */
238
+ function extractSNI(data) {
239
+ try {
240
+ // TLS record: type=22 (handshake), version, length
241
+ if (data.length < 5 || data[0] !== 0x16)
242
+ return null;
243
+ // Handshake: type=1 (ClientHello)
244
+ let offset = 5;
245
+ if (data[offset] !== 0x01)
246
+ return null;
247
+ // Skip handshake length (3 bytes) + client version (2) + random (32)
248
+ offset += 4 + 2 + 32;
249
+ // Session ID
250
+ const sessionIdLen = data[offset];
251
+ offset += 1 + sessionIdLen;
252
+ // Cipher suites
253
+ const cipherLen = data.readUInt16BE(offset);
254
+ offset += 2 + cipherLen;
255
+ // Compression methods
256
+ const compLen = data[offset];
257
+ offset += 1 + compLen;
258
+ // Extensions
259
+ if (offset + 2 > data.length)
260
+ return null;
261
+ const extLen = data.readUInt16BE(offset);
262
+ offset += 2;
263
+ const extEnd = offset + extLen;
264
+ while (offset + 4 < extEnd && offset < data.length) {
265
+ const extType = data.readUInt16BE(offset);
266
+ const extDataLen = data.readUInt16BE(offset + 2);
267
+ offset += 4;
268
+ if (extType === 0x0000) { // SNI extension
269
+ // Skip SNI list length (2)
270
+ offset += 2;
271
+ const nameType = data[offset];
272
+ offset += 1;
273
+ if (nameType === 0x00) { // hostname
274
+ const nameLen = data.readUInt16BE(offset);
275
+ offset += 2;
276
+ return data.subarray(offset, offset + nameLen).toString('ascii');
277
+ }
278
+ }
279
+ offset += extDataLen;
280
+ }
281
+ }
282
+ catch { }
283
+ return null;
284
+ }
285
+ //# sourceMappingURL=transparent-listener.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Resolve and cache real IPs before overriding DNS.
3
+ * MUST be called before enableDNSRedirect.
4
+ */
5
+ export declare function cacheRealIPs(): Promise<Map<string, string[]>>;
6
+ /** Get cached real IP for a hostname */
7
+ export declare function getRealIP(hostname: string): string | null;
8
+ /** Get all original IP mappings */
9
+ export declare function getAllRealIPs(): Map<string, string[]>;
10
+ /**
11
+ * Add /etc/hosts entries pointing LLM hostnames to 127.0.0.1.
12
+ * Requires sudo.
13
+ * After this, all apps resolve LLM hosts to localhost → our proxy.
14
+ */
15
+ export declare function enableDNSRedirect(): {
16
+ success: boolean;
17
+ message: string;
18
+ hosts: number;
19
+ };
20
+ /**
21
+ * Remove /etc/hosts entries.
22
+ */
23
+ export declare function disableDNSRedirect(): {
24
+ success: boolean;
25
+ message: string;
26
+ };
27
+ /** Check if DNS redirect is active */
28
+ export declare function isDNSRedirectActive(): boolean;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.cacheRealIPs = cacheRealIPs;
7
+ exports.getRealIP = getRealIP;
8
+ exports.getAllRealIPs = getAllRealIPs;
9
+ exports.enableDNSRedirect = enableDNSRedirect;
10
+ exports.disableDNSRedirect = disableDNSRedirect;
11
+ exports.isDNSRedirectActive = isDNSRedirectActive;
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_child_process_1 = require("node:child_process");
14
+ const node_dns_1 = __importDefault(require("node:dns"));
15
+ const HOSTS_FILE = '/etc/hosts';
16
+ const MARKER_START = '# SmartContext Proxy — START (auto-generated, do not edit)';
17
+ const MARKER_END = '# SmartContext Proxy — END';
18
+ /** LLM provider hostnames to redirect */
19
+ const LLM_HOSTS = [
20
+ 'api.anthropic.com',
21
+ 'api.openai.com',
22
+ 'generativelanguage.googleapis.com',
23
+ 'openrouter.ai',
24
+ 'api.together.xyz',
25
+ 'api.fireworks.ai',
26
+ 'api.mistral.ai',
27
+ 'api.cohere.com',
28
+ 'api.groq.com',
29
+ 'api.deepseek.com',
30
+ ];
31
+ /** Original IP mappings — needed to forward traffic to real providers */
32
+ const originalIPs = new Map();
33
+ /**
34
+ * Resolve and cache real IPs before overriding DNS.
35
+ * MUST be called before enableDNSRedirect.
36
+ */
37
+ async function cacheRealIPs() {
38
+ for (const host of LLM_HOSTS) {
39
+ try {
40
+ const addrs = await new Promise((resolve, reject) => {
41
+ node_dns_1.default.resolve4(host, (err, addresses) => {
42
+ if (err)
43
+ reject(err);
44
+ else
45
+ resolve(addresses);
46
+ });
47
+ });
48
+ originalIPs.set(host, addrs);
49
+ }
50
+ catch { }
51
+ }
52
+ return originalIPs;
53
+ }
54
+ /** Get cached real IP for a hostname */
55
+ function getRealIP(hostname) {
56
+ const ips = originalIPs.get(hostname);
57
+ return ips?.[0] || null;
58
+ }
59
+ /** Get all original IP mappings */
60
+ function getAllRealIPs() {
61
+ return originalIPs;
62
+ }
63
+ /**
64
+ * Add /etc/hosts entries pointing LLM hostnames to 127.0.0.1.
65
+ * Requires sudo.
66
+ * After this, all apps resolve LLM hosts to localhost → our proxy.
67
+ */
68
+ function enableDNSRedirect() {
69
+ try {
70
+ const current = node_fs_1.default.readFileSync(HOSTS_FILE, 'utf-8');
71
+ // Don't add if already present
72
+ if (current.includes(MARKER_START)) {
73
+ return { success: true, message: 'DNS redirect already active', hosts: LLM_HOSTS.length };
74
+ }
75
+ const block = [
76
+ '',
77
+ MARKER_START,
78
+ ...LLM_HOSTS.map((h) => `127.0.0.1 ${h}`),
79
+ MARKER_END,
80
+ '',
81
+ ].join('\n');
82
+ const newContent = current + block;
83
+ // Write via sudo tee
84
+ (0, node_child_process_1.execFileSync)('sudo', ['tee', HOSTS_FILE], {
85
+ input: newContent,
86
+ stdio: ['pipe', 'pipe', 'pipe'],
87
+ });
88
+ // Flush DNS cache
89
+ flushDNS();
90
+ return { success: true, message: `DNS redirect active: ${LLM_HOSTS.length} hosts → 127.0.0.1`, hosts: LLM_HOSTS.length };
91
+ }
92
+ catch (err) {
93
+ return { success: false, message: `Failed: ${err}`, hosts: 0 };
94
+ }
95
+ }
96
+ /**
97
+ * Remove /etc/hosts entries.
98
+ */
99
+ function disableDNSRedirect() {
100
+ try {
101
+ const current = node_fs_1.default.readFileSync(HOSTS_FILE, 'utf-8');
102
+ if (!current.includes(MARKER_START)) {
103
+ return { success: true, message: 'DNS redirect not active' };
104
+ }
105
+ // Remove our block
106
+ const regex = new RegExp(`\\n?${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n?`, 'g');
107
+ const cleaned = current.replace(regex, '\n');
108
+ (0, node_child_process_1.execFileSync)('sudo', ['tee', HOSTS_FILE], {
109
+ input: cleaned,
110
+ stdio: ['pipe', 'pipe', 'pipe'],
111
+ });
112
+ flushDNS();
113
+ return { success: true, message: 'DNS redirect removed' };
114
+ }
115
+ catch (err) {
116
+ return { success: false, message: `Failed: ${err}` };
117
+ }
118
+ }
119
+ /** Check if DNS redirect is active */
120
+ function isDNSRedirectActive() {
121
+ try {
122
+ const content = node_fs_1.default.readFileSync(HOSTS_FILE, 'utf-8');
123
+ return content.includes(MARKER_START);
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ function flushDNS() {
130
+ try {
131
+ if (process.platform === 'darwin') {
132
+ (0, node_child_process_1.execFileSync)('dscacheutil', ['-flushcache'], { stdio: 'pipe' });
133
+ (0, node_child_process_1.execFileSync)('sudo', ['killall', '-HUP', 'mDNSResponder'], { stdio: 'pipe' });
134
+ }
135
+ }
136
+ catch { }
137
+ }
138
+ function escapeRegex(s) {
139
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
140
+ }
141
+ //# sourceMappingURL=dns-redirect.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Install pf redirect rules.
3
+ * Requires sudo.
4
+ */
5
+ export declare function enablePFRedirect(proxyPort: number): Promise<{
6
+ success: boolean;
7
+ message: string;
8
+ ips: number;
9
+ }>;
10
+ /**
11
+ * Remove pf redirect rules.
12
+ */
13
+ export declare function disablePFRedirect(): {
14
+ success: boolean;
15
+ message: string;
16
+ };
17
+ /**
18
+ * Check if pf redirect is active.
19
+ */
20
+ export declare function isPFRedirectActive(): boolean;
21
+ /**
22
+ * Refresh IP addresses (IPs can change due to DNS).
23
+ * Call periodically to keep rules current.
24
+ */
25
+ export declare function refreshPFRules(proxyPort: number): Promise<void>;