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,177 @@
|
|
|
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.enablePFRedirect = enablePFRedirect;
|
|
7
|
+
exports.disablePFRedirect = disablePFRedirect;
|
|
8
|
+
exports.isPFRedirectActive = isPFRedirectActive;
|
|
9
|
+
exports.refreshPFRules = refreshPFRules;
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_dns_1 = __importDefault(require("node:dns"));
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const PF_ANCHOR = 'com.smartcontext';
|
|
15
|
+
const PF_CONF_PATH = node_path_1.default.join(process.env['HOME'] || '.', '.smartcontext', 'pf-smartcontext.conf');
|
|
16
|
+
/** Known LLM provider hostnames to intercept */
|
|
17
|
+
const LLM_HOSTS = [
|
|
18
|
+
'api.anthropic.com',
|
|
19
|
+
'api.openai.com',
|
|
20
|
+
'generativelanguage.googleapis.com',
|
|
21
|
+
'openrouter.ai',
|
|
22
|
+
'api.together.xyz',
|
|
23
|
+
'api.fireworks.ai',
|
|
24
|
+
'api.mistral.ai',
|
|
25
|
+
'api.cohere.com',
|
|
26
|
+
'api.groq.com',
|
|
27
|
+
'api.deepseek.com',
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Resolve hostnames to IPs for pf rules.
|
|
31
|
+
* pf works at IP level, not DNS.
|
|
32
|
+
*/
|
|
33
|
+
async function resolveHosts() {
|
|
34
|
+
const results = new Map();
|
|
35
|
+
for (const host of LLM_HOSTS) {
|
|
36
|
+
try {
|
|
37
|
+
const addrs = await new Promise((resolve, reject) => {
|
|
38
|
+
node_dns_1.default.resolve4(host, (err, addresses) => {
|
|
39
|
+
if (err)
|
|
40
|
+
reject(err);
|
|
41
|
+
else
|
|
42
|
+
resolve(addresses);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
results.set(host, addrs);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Host might not resolve — skip
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Generate pf redirect rules.
|
|
55
|
+
* Redirects outgoing HTTPS (port 443) traffic to LLM provider IPs
|
|
56
|
+
* to our local proxy port.
|
|
57
|
+
*/
|
|
58
|
+
function generatePFConf(hostIPs, proxyPort) {
|
|
59
|
+
const lines = [
|
|
60
|
+
`# SmartContext Proxy — auto-generated pf rules`,
|
|
61
|
+
`# Redirects LLM API traffic to local proxy`,
|
|
62
|
+
``,
|
|
63
|
+
];
|
|
64
|
+
// Collect all IPs into a pf table
|
|
65
|
+
const allIPs = [];
|
|
66
|
+
for (const [host, ips] of hostIPs) {
|
|
67
|
+
lines.push(`# ${host}: ${ips.join(', ')}`);
|
|
68
|
+
allIPs.push(...ips);
|
|
69
|
+
}
|
|
70
|
+
if (allIPs.length === 0) {
|
|
71
|
+
lines.push('# No IPs resolved — no rules');
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(`table <llm_providers> { ${allIPs.join(', ')} }`);
|
|
76
|
+
lines.push('');
|
|
77
|
+
// Redirect outgoing HTTPS to LLM providers → local proxy
|
|
78
|
+
// rdr-to changes destination to localhost:proxyPort
|
|
79
|
+
lines.push(`rdr pass on lo0 proto tcp from any to <llm_providers> port 443 -> 127.0.0.1 port ${proxyPort}`);
|
|
80
|
+
lines.push('');
|
|
81
|
+
// Route the traffic through loopback so rdr applies
|
|
82
|
+
lines.push(`pass out on en0 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
|
|
83
|
+
lines.push(`pass out on en1 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
|
|
84
|
+
lines.push('');
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Install pf redirect rules.
|
|
89
|
+
* Requires sudo.
|
|
90
|
+
*/
|
|
91
|
+
async function enablePFRedirect(proxyPort) {
|
|
92
|
+
try {
|
|
93
|
+
const hostIPs = await resolveHosts();
|
|
94
|
+
let totalIPs = 0;
|
|
95
|
+
for (const ips of hostIPs.values())
|
|
96
|
+
totalIPs += ips.length;
|
|
97
|
+
if (totalIPs === 0) {
|
|
98
|
+
return { success: false, message: 'No LLM provider IPs resolved', ips: 0 };
|
|
99
|
+
}
|
|
100
|
+
const conf = generatePFConf(hostIPs, proxyPort);
|
|
101
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(PF_CONF_PATH), { recursive: true });
|
|
102
|
+
node_fs_1.default.writeFileSync(PF_CONF_PATH, conf);
|
|
103
|
+
// Load anchor into pf
|
|
104
|
+
try {
|
|
105
|
+
(0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
return { success: false, message: `pfctl load failed: ${err}`, ips: totalIPs };
|
|
109
|
+
}
|
|
110
|
+
// Enable pf if not already enabled
|
|
111
|
+
try {
|
|
112
|
+
(0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-e'], { stdio: 'pipe' });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Already enabled — ok
|
|
116
|
+
}
|
|
117
|
+
return { success: true, message: `pf redirect active: ${totalIPs} IPs from ${hostIPs.size} hosts → :${proxyPort}`, ips: totalIPs };
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return { success: false, message: `Failed: ${err}`, ips: 0 };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Remove pf redirect rules.
|
|
125
|
+
*/
|
|
126
|
+
function disablePFRedirect() {
|
|
127
|
+
try {
|
|
128
|
+
// Flush anchor rules
|
|
129
|
+
try {
|
|
130
|
+
(0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-F', 'all'], { stdio: 'pipe' });
|
|
131
|
+
}
|
|
132
|
+
catch { }
|
|
133
|
+
// Remove conf file
|
|
134
|
+
try {
|
|
135
|
+
node_fs_1.default.unlinkSync(PF_CONF_PATH);
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
return { success: true, message: 'pf redirect removed' };
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return { success: false, message: `Failed: ${err}` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Check if pf redirect is active.
|
|
146
|
+
*/
|
|
147
|
+
function isPFRedirectActive() {
|
|
148
|
+
try {
|
|
149
|
+
const result = (0, node_child_process_1.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
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Refresh IP addresses (IPs can change due to DNS).
|
|
161
|
+
* Call periodically to keep rules current.
|
|
162
|
+
*/
|
|
163
|
+
async function refreshPFRules(proxyPort) {
|
|
164
|
+
const hostIPs = await resolveHosts();
|
|
165
|
+
let totalIPs = 0;
|
|
166
|
+
for (const ips of hostIPs.values())
|
|
167
|
+
totalIPs += ips.length;
|
|
168
|
+
if (totalIPs === 0)
|
|
169
|
+
return;
|
|
170
|
+
const conf = generatePFConf(hostIPs, proxyPort);
|
|
171
|
+
node_fs_1.default.writeFileSync(PF_CONF_PATH, conf);
|
|
172
|
+
try {
|
|
173
|
+
(0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
|
|
174
|
+
}
|
|
175
|
+
catch { }
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=pf-redirect.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start watchdog that monitors proxy health.
|
|
3
|
+
* If proxy becomes unreachable, auto-removes system proxy config
|
|
4
|
+
* to prevent breaking the user's internet.
|
|
5
|
+
*/
|
|
6
|
+
export declare function startWatchdog(proxyPort: number): void;
|
|
7
|
+
export declare function stopWatchdog(): void;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.startWatchdog = startWatchdog;
|
|
40
|
+
exports.stopWatchdog = stopWatchdog;
|
|
41
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
42
|
+
const macos = __importStar(require("./macos.js"));
|
|
43
|
+
const linux = __importStar(require("./linux.js"));
|
|
44
|
+
const CHECK_INTERVAL = 10_000; // 10 seconds
|
|
45
|
+
let watchdogTimer = null;
|
|
46
|
+
let port = 4800;
|
|
47
|
+
/**
|
|
48
|
+
* Start watchdog that monitors proxy health.
|
|
49
|
+
* If proxy becomes unreachable, auto-removes system proxy config
|
|
50
|
+
* to prevent breaking the user's internet.
|
|
51
|
+
*/
|
|
52
|
+
function startWatchdog(proxyPort) {
|
|
53
|
+
port = proxyPort;
|
|
54
|
+
watchdogTimer = setInterval(() => {
|
|
55
|
+
checkHealth().catch(() => {
|
|
56
|
+
console.log('[watchdog] Proxy unreachable — clearing system proxy');
|
|
57
|
+
emergencyClearProxy();
|
|
58
|
+
stopWatchdog();
|
|
59
|
+
});
|
|
60
|
+
}, CHECK_INTERVAL);
|
|
61
|
+
// Also clear proxy on process exit
|
|
62
|
+
process.on('exit', emergencyClearProxy);
|
|
63
|
+
process.on('uncaughtException', (err) => {
|
|
64
|
+
console.error('[watchdog] Uncaught exception:', err);
|
|
65
|
+
emergencyClearProxy();
|
|
66
|
+
process.exit(1);
|
|
67
|
+
});
|
|
68
|
+
process.on('unhandledRejection', (err) => {
|
|
69
|
+
console.error('[watchdog] Unhandled rejection:', err);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function stopWatchdog() {
|
|
73
|
+
if (watchdogTimer) {
|
|
74
|
+
clearInterval(watchdogTimer);
|
|
75
|
+
watchdogTimer = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function checkHealth() {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const req = node_http_1.default.get(`http://127.0.0.1:${port}/health`, { timeout: 5000 }, (res) => {
|
|
81
|
+
let data = '';
|
|
82
|
+
res.on('data', (chunk) => (data += chunk));
|
|
83
|
+
res.on('end', () => {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(data);
|
|
86
|
+
if (parsed.ok)
|
|
87
|
+
resolve();
|
|
88
|
+
else
|
|
89
|
+
reject(new Error('Health check failed'));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
reject(new Error('Invalid health response'));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
req.on('error', reject);
|
|
97
|
+
req.on('timeout', () => {
|
|
98
|
+
req.destroy();
|
|
99
|
+
reject(new Error('Health check timeout'));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function emergencyClearProxy() {
|
|
104
|
+
try {
|
|
105
|
+
if (process.platform === 'darwin') {
|
|
106
|
+
macos.clearAutoproxyURL();
|
|
107
|
+
macos.clearSystemProxy();
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
linux.clearSystemProxy();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=watchdog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
const node_test_1 = require("node:test");
|
|
7
|
+
const node_assert_1 = __importDefault(require("node:assert"));
|
|
8
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const connect_proxy_js_1 = require("../proxy/connect-proxy.js");
|
|
11
|
+
const auto_detect_js_1 = require("../config/auto-detect.js");
|
|
12
|
+
const ca_manager_js_1 = require("../tls/ca-manager.js");
|
|
13
|
+
const classifier_js_1 = require("../proxy/classifier.js");
|
|
14
|
+
function httpRequest(url, options) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const req = node_http_1.default.request(url, options, (res) => {
|
|
17
|
+
let data = '';
|
|
18
|
+
res.on('data', (chunk) => (data += chunk));
|
|
19
|
+
res.on('end', () => resolve({ status: res.statusCode || 0, body: data }));
|
|
20
|
+
});
|
|
21
|
+
req.on('error', reject);
|
|
22
|
+
req.end();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
(0, node_test_1.describe)('Traffic Classifier', () => {
|
|
26
|
+
(0, node_test_1.it)('identifies Anthropic as LLM', () => {
|
|
27
|
+
const match = (0, classifier_js_1.classifyHost)('api.anthropic.com', 443);
|
|
28
|
+
node_assert_1.default.ok(match);
|
|
29
|
+
node_assert_1.default.strictEqual(match.provider, 'anthropic');
|
|
30
|
+
});
|
|
31
|
+
(0, node_test_1.it)('identifies OpenAI as LLM', () => {
|
|
32
|
+
const match = (0, classifier_js_1.classifyHost)('api.openai.com', 443);
|
|
33
|
+
node_assert_1.default.ok(match);
|
|
34
|
+
node_assert_1.default.strictEqual(match.provider, 'openai');
|
|
35
|
+
});
|
|
36
|
+
(0, node_test_1.it)('identifies Ollama local as LLM', () => {
|
|
37
|
+
const match = (0, classifier_js_1.classifyHost)('localhost', 11434);
|
|
38
|
+
node_assert_1.default.ok(match);
|
|
39
|
+
node_assert_1.default.strictEqual(match.provider, 'ollama');
|
|
40
|
+
});
|
|
41
|
+
(0, node_test_1.it)('returns null for non-LLM hosts', () => {
|
|
42
|
+
node_assert_1.default.strictEqual((0, classifier_js_1.classifyHost)('google.com', 443), null);
|
|
43
|
+
node_assert_1.default.strictEqual((0, classifier_js_1.classifyHost)('github.com', 443), null);
|
|
44
|
+
node_assert_1.default.strictEqual((0, classifier_js_1.classifyHost)('localhost', 3000), null);
|
|
45
|
+
});
|
|
46
|
+
(0, node_test_1.it)('identifies all known providers', () => {
|
|
47
|
+
const providers = [
|
|
48
|
+
['api.anthropic.com', 'anthropic'],
|
|
49
|
+
['api.openai.com', 'openai'],
|
|
50
|
+
['generativelanguage.googleapis.com', 'google'],
|
|
51
|
+
['openrouter.ai', 'openrouter'],
|
|
52
|
+
['api.groq.com', 'groq'],
|
|
53
|
+
['api.deepseek.com', 'deepseek'],
|
|
54
|
+
];
|
|
55
|
+
for (const [host, expected] of providers) {
|
|
56
|
+
const match = (0, classifier_js_1.classifyHost)(host, 443);
|
|
57
|
+
node_assert_1.default.ok(match, `Should match ${host}`);
|
|
58
|
+
node_assert_1.default.strictEqual(match.provider, expected);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
(0, node_test_1.describe)('CA Manager', () => {
|
|
63
|
+
(0, node_test_1.it)('generates CA cert on first call', () => {
|
|
64
|
+
const result = (0, ca_manager_js_1.ensureCA)();
|
|
65
|
+
node_assert_1.default.ok(result.cert.includes('BEGIN CERTIFICATE'));
|
|
66
|
+
node_assert_1.default.ok(result.key.includes('BEGIN RSA PRIVATE KEY'));
|
|
67
|
+
});
|
|
68
|
+
(0, node_test_1.it)('returns same cert on subsequent calls', () => {
|
|
69
|
+
const a = (0, ca_manager_js_1.ensureCA)();
|
|
70
|
+
const b = (0, ca_manager_js_1.ensureCA)();
|
|
71
|
+
node_assert_1.default.strictEqual(a.cert, b.cert);
|
|
72
|
+
});
|
|
73
|
+
(0, node_test_1.it)('CA cert file exists on disk', () => {
|
|
74
|
+
node_assert_1.default.ok(node_fs_1.default.existsSync((0, ca_manager_js_1.getCACertPath)()));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
(0, node_test_1.describe)('CONNECT Proxy', () => {
|
|
78
|
+
let proxy;
|
|
79
|
+
const PROXY_PORT = 14820;
|
|
80
|
+
(0, node_test_1.before)(async () => {
|
|
81
|
+
(0, ca_manager_js_1.ensureCA)();
|
|
82
|
+
const config = (0, auto_detect_js_1.buildConfig)({ proxy: { port: PROXY_PORT, host: '127.0.0.1' } });
|
|
83
|
+
config.logging.level = 'error';
|
|
84
|
+
proxy = new connect_proxy_js_1.ConnectProxy(config);
|
|
85
|
+
await proxy.start();
|
|
86
|
+
});
|
|
87
|
+
(0, node_test_1.after)(async () => {
|
|
88
|
+
await proxy.stop();
|
|
89
|
+
});
|
|
90
|
+
(0, node_test_1.it)('serves dashboard at root', async () => {
|
|
91
|
+
const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/`, { method: 'GET' });
|
|
92
|
+
node_assert_1.default.strictEqual(res.status, 200);
|
|
93
|
+
node_assert_1.default.ok(res.body.includes('SmartContext'));
|
|
94
|
+
});
|
|
95
|
+
(0, node_test_1.it)('serves health endpoint', async () => {
|
|
96
|
+
const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/health`, { method: 'GET' });
|
|
97
|
+
node_assert_1.default.strictEqual(res.status, 200);
|
|
98
|
+
const data = JSON.parse(res.body);
|
|
99
|
+
node_assert_1.default.strictEqual(data.ok, true);
|
|
100
|
+
node_assert_1.default.strictEqual(data.type, 'connect-proxy');
|
|
101
|
+
});
|
|
102
|
+
(0, node_test_1.it)('serves PAC file', async () => {
|
|
103
|
+
const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/proxy.pac`, { method: 'GET' });
|
|
104
|
+
node_assert_1.default.strictEqual(res.status, 200);
|
|
105
|
+
node_assert_1.default.ok(res.body.includes('FindProxyForURL'));
|
|
106
|
+
node_assert_1.default.ok(res.body.includes('api.anthropic.com'));
|
|
107
|
+
node_assert_1.default.ok(res.body.includes('api.openai.com'));
|
|
108
|
+
});
|
|
109
|
+
(0, node_test_1.it)('API endpoints work', async () => {
|
|
110
|
+
const status = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/_sc/status`, { method: 'GET' });
|
|
111
|
+
node_assert_1.default.strictEqual(JSON.parse(status.body).state, 'running');
|
|
112
|
+
const stats = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/_sc/stats`, { method: 'GET' });
|
|
113
|
+
node_assert_1.default.strictEqual(JSON.parse(stats.body).totalRequests, 0);
|
|
114
|
+
});
|
|
115
|
+
(0, node_test_1.it)('handles CONNECT to non-LLM host (tunnel)', async () => {
|
|
116
|
+
// Create a simple target server
|
|
117
|
+
const targetServer = node_http_1.default.createServer((req, res) => {
|
|
118
|
+
res.writeHead(200);
|
|
119
|
+
res.end('target-ok');
|
|
120
|
+
});
|
|
121
|
+
await new Promise((r) => targetServer.listen(14821, '127.0.0.1', r));
|
|
122
|
+
// Send CONNECT through proxy
|
|
123
|
+
const tunnelOk = await new Promise((resolve) => {
|
|
124
|
+
const req = node_http_1.default.request({
|
|
125
|
+
host: '127.0.0.1',
|
|
126
|
+
port: PROXY_PORT,
|
|
127
|
+
method: 'CONNECT',
|
|
128
|
+
path: '127.0.0.1:14821',
|
|
129
|
+
});
|
|
130
|
+
req.on('connect', (res, socket) => {
|
|
131
|
+
// Send HTTP request through tunnel
|
|
132
|
+
socket.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n');
|
|
133
|
+
let data = '';
|
|
134
|
+
socket.on('data', (chunk) => { data += chunk; });
|
|
135
|
+
socket.on('end', () => {
|
|
136
|
+
socket.destroy();
|
|
137
|
+
resolve(data.includes('target-ok'));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
req.on('error', () => resolve(false));
|
|
141
|
+
req.end();
|
|
142
|
+
});
|
|
143
|
+
targetServer.close();
|
|
144
|
+
node_assert_1.default.ok(tunnelOk, 'Should tunnel non-LLM traffic');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
//# sourceMappingURL=connect-proxy.test.js.map
|
|
@@ -40,6 +40,7 @@ function httpRequest(url, options, body) {
|
|
|
40
40
|
node_assert_1.default.ok(res.headers['content-type']?.includes('text/html'));
|
|
41
41
|
node_assert_1.default.ok(res.body.includes('SmartContext Proxy'));
|
|
42
42
|
node_assert_1.default.ok(res.body.includes('Total Requests'));
|
|
43
|
+
node_assert_1.default.ok(res.body.includes('Settings'));
|
|
43
44
|
});
|
|
44
45
|
(0, node_test_1.it)('returns status via /_sc/status', async () => {
|
|
45
46
|
const res = await httpRequest(`http://127.0.0.1:${PORT}/_sc/status`, { method: 'GET' });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface CertPair {
|
|
2
|
+
cert: string;
|
|
3
|
+
key: string;
|
|
4
|
+
}
|
|
5
|
+
/** Ensure root CA exists, generate if not */
|
|
6
|
+
export declare function ensureCA(): CertPair;
|
|
7
|
+
/** Generate a TLS certificate for a specific hostname, signed by our CA */
|
|
8
|
+
export declare function getCertForHost(hostname: string): CertPair;
|
|
9
|
+
export declare function getCACertPath(): string;
|
|
@@ -0,0 +1,117 @@
|
|
|
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.ensureCA = ensureCA;
|
|
7
|
+
exports.getCertForHost = getCertForHost;
|
|
8
|
+
exports.getCACertPath = getCACertPath;
|
|
9
|
+
const node_forge_1 = __importDefault(require("node-forge"));
|
|
10
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const CA_DIR = node_path_1.default.join(process.env['HOME'] || '.', '.smartcontext', 'ca');
|
|
13
|
+
const HOSTS_DIR = node_path_1.default.join(CA_DIR, 'hosts');
|
|
14
|
+
const CA_CERT_PATH = node_path_1.default.join(CA_DIR, 'smartcontext-ca.crt');
|
|
15
|
+
const CA_KEY_PATH = node_path_1.default.join(CA_DIR, 'smartcontext-ca.key');
|
|
16
|
+
let cachedCA = null;
|
|
17
|
+
const hostCertCache = new Map();
|
|
18
|
+
/** Ensure root CA exists, generate if not */
|
|
19
|
+
function ensureCA() {
|
|
20
|
+
node_fs_1.default.mkdirSync(CA_DIR, { recursive: true });
|
|
21
|
+
node_fs_1.default.mkdirSync(HOSTS_DIR, { recursive: true });
|
|
22
|
+
if (node_fs_1.default.existsSync(CA_CERT_PATH) && node_fs_1.default.existsSync(CA_KEY_PATH)) {
|
|
23
|
+
const cert = node_fs_1.default.readFileSync(CA_CERT_PATH, 'utf-8');
|
|
24
|
+
const key = node_fs_1.default.readFileSync(CA_KEY_PATH, 'utf-8');
|
|
25
|
+
cachedCA = {
|
|
26
|
+
cert: node_forge_1.default.pki.certificateFromPem(cert),
|
|
27
|
+
key: node_forge_1.default.pki.privateKeyFromPem(key),
|
|
28
|
+
};
|
|
29
|
+
return { cert, key };
|
|
30
|
+
}
|
|
31
|
+
// Generate new root CA
|
|
32
|
+
const keys = node_forge_1.default.pki.rsa.generateKeyPair(2048);
|
|
33
|
+
const cert = node_forge_1.default.pki.createCertificate();
|
|
34
|
+
cert.publicKey = keys.publicKey;
|
|
35
|
+
cert.serialNumber = generateSerial();
|
|
36
|
+
cert.validity.notBefore = new Date();
|
|
37
|
+
cert.validity.notAfter = new Date();
|
|
38
|
+
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 10);
|
|
39
|
+
const attrs = [
|
|
40
|
+
{ name: 'commonName', value: 'SmartContext Proxy CA' },
|
|
41
|
+
{ name: 'organizationName', value: 'SmartContext' },
|
|
42
|
+
];
|
|
43
|
+
cert.setSubject(attrs);
|
|
44
|
+
cert.setIssuer(attrs);
|
|
45
|
+
cert.setExtensions([
|
|
46
|
+
{ name: 'basicConstraints', cA: true, critical: true },
|
|
47
|
+
{ name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
|
|
48
|
+
{ name: 'subjectKeyIdentifier' },
|
|
49
|
+
]);
|
|
50
|
+
cert.sign(keys.privateKey, node_forge_1.default.md.sha256.create());
|
|
51
|
+
const certPem = node_forge_1.default.pki.certificateToPem(cert);
|
|
52
|
+
const keyPem = node_forge_1.default.pki.privateKeyToPem(keys.privateKey);
|
|
53
|
+
node_fs_1.default.writeFileSync(CA_CERT_PATH, certPem);
|
|
54
|
+
node_fs_1.default.writeFileSync(CA_KEY_PATH, keyPem, { mode: 0o600 });
|
|
55
|
+
cachedCA = { cert, key: keys.privateKey };
|
|
56
|
+
return { cert: certPem, key: keyPem };
|
|
57
|
+
}
|
|
58
|
+
/** Generate a TLS certificate for a specific hostname, signed by our CA */
|
|
59
|
+
function getCertForHost(hostname) {
|
|
60
|
+
// Check memory cache
|
|
61
|
+
const cached = hostCertCache.get(hostname);
|
|
62
|
+
if (cached)
|
|
63
|
+
return cached;
|
|
64
|
+
// Check disk cache
|
|
65
|
+
const certPath = node_path_1.default.join(HOSTS_DIR, `${hostname}.crt`);
|
|
66
|
+
const keyPath = node_path_1.default.join(HOSTS_DIR, `${hostname}.key`);
|
|
67
|
+
if (node_fs_1.default.existsSync(certPath) && node_fs_1.default.existsSync(keyPath)) {
|
|
68
|
+
const pair = {
|
|
69
|
+
cert: node_fs_1.default.readFileSync(certPath, 'utf-8'),
|
|
70
|
+
key: node_fs_1.default.readFileSync(keyPath, 'utf-8'),
|
|
71
|
+
};
|
|
72
|
+
hostCertCache.set(hostname, pair);
|
|
73
|
+
return pair;
|
|
74
|
+
}
|
|
75
|
+
// Generate new cert
|
|
76
|
+
if (!cachedCA)
|
|
77
|
+
ensureCA();
|
|
78
|
+
const keys = node_forge_1.default.pki.rsa.generateKeyPair(2048);
|
|
79
|
+
const cert = node_forge_1.default.pki.createCertificate();
|
|
80
|
+
cert.publicKey = keys.publicKey;
|
|
81
|
+
cert.serialNumber = generateSerial();
|
|
82
|
+
cert.validity.notBefore = new Date();
|
|
83
|
+
cert.validity.notAfter = new Date();
|
|
84
|
+
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
|
|
85
|
+
cert.setSubject([{ name: 'commonName', value: hostname }]);
|
|
86
|
+
cert.setIssuer(cachedCA.cert.subject.attributes);
|
|
87
|
+
cert.setExtensions([
|
|
88
|
+
{ name: 'basicConstraints', cA: false },
|
|
89
|
+
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
|
|
90
|
+
{ name: 'extKeyUsage', serverAuth: true },
|
|
91
|
+
{
|
|
92
|
+
name: 'subjectAltName',
|
|
93
|
+
altNames: [
|
|
94
|
+
{ type: 2, value: hostname }, // DNS
|
|
95
|
+
...(isIP(hostname) ? [{ type: 7, ip: hostname }] : []),
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
cert.sign(cachedCA.key, node_forge_1.default.md.sha256.create());
|
|
100
|
+
const certPem = node_forge_1.default.pki.certificateToPem(cert);
|
|
101
|
+
const keyPem = node_forge_1.default.pki.privateKeyToPem(keys.privateKey);
|
|
102
|
+
node_fs_1.default.writeFileSync(certPath, certPem);
|
|
103
|
+
node_fs_1.default.writeFileSync(keyPath, keyPem, { mode: 0o600 });
|
|
104
|
+
const pair = { cert: certPem, key: keyPem };
|
|
105
|
+
hostCertCache.set(hostname, pair);
|
|
106
|
+
return pair;
|
|
107
|
+
}
|
|
108
|
+
function getCACertPath() {
|
|
109
|
+
return CA_CERT_PATH;
|
|
110
|
+
}
|
|
111
|
+
function generateSerial() {
|
|
112
|
+
return Date.now().toString(16) + Math.random().toString(16).slice(2, 10);
|
|
113
|
+
}
|
|
114
|
+
function isIP(str) {
|
|
115
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(str) || str.includes(':');
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=ca-manager.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface TrustStoreResult {
|
|
2
|
+
success: boolean;
|
|
3
|
+
message: string;
|
|
4
|
+
requiresSudo: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Install CA cert into system trust store */
|
|
7
|
+
export declare function installCA(): TrustStoreResult;
|
|
8
|
+
/** Remove CA cert from system trust store */
|
|
9
|
+
export declare function uninstallCA(): TrustStoreResult;
|
|
10
|
+
/** Check if CA is installed in trust store */
|
|
11
|
+
export declare function isCAInstalled(): boolean;
|