smartcontext-proxy 0.1.0 → 0.2.0
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 +34 -0
- package/dist/src/proxy/connect-proxy.js +167 -0
- package/dist/src/proxy/tls-interceptor.d.ts +23 -0
- package/dist/src/proxy/tls-interceptor.js +211 -0
- package/dist/src/proxy/tunnel.d.ts +7 -0
- package/dist/src/proxy/tunnel.js +33 -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/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/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/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 +187 -0
- package/src/proxy/tls-interceptor.ts +261 -0
- package/src/proxy/tunnel.ts +32 -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/watchdog.ts +76 -0
- package/src/test/connect-proxy.test.ts +170 -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/ws-feed.ts +32 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/** Configure Linux system proxy */
|
|
5
|
+
export function setSystemProxy(host: string, port: number): { success: boolean; message: string } {
|
|
6
|
+
const proxyUrl = `http://${host}:${port}`;
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
// Try GNOME settings
|
|
10
|
+
try {
|
|
11
|
+
execFileSync('gsettings', ['set', 'org.gnome.system.proxy', 'mode', "'manual'"], { stdio: 'pipe' });
|
|
12
|
+
execFileSync('gsettings', ['set', 'org.gnome.system.proxy.http', 'host', `'${host}'`], { stdio: 'pipe' });
|
|
13
|
+
execFileSync('gsettings', ['set', 'org.gnome.system.proxy.http', 'port', String(port)], { stdio: 'pipe' });
|
|
14
|
+
execFileSync('gsettings', ['set', 'org.gnome.system.proxy.https', 'host', `'${host}'`], { stdio: 'pipe' });
|
|
15
|
+
execFileSync('gsettings', ['set', 'org.gnome.system.proxy.https', 'port', String(port)], { stdio: 'pipe' });
|
|
16
|
+
} catch {}
|
|
17
|
+
|
|
18
|
+
// Write environment file for shell sessions
|
|
19
|
+
const envFile = '/etc/profile.d/smartcontext-proxy.sh';
|
|
20
|
+
const content = `# SmartContext Proxy - auto-generated
|
|
21
|
+
export http_proxy="${proxyUrl}"
|
|
22
|
+
export https_proxy="${proxyUrl}"
|
|
23
|
+
export HTTP_PROXY="${proxyUrl}"
|
|
24
|
+
export HTTPS_PROXY="${proxyUrl}"
|
|
25
|
+
export no_proxy="localhost,127.0.0.1,::1"
|
|
26
|
+
`;
|
|
27
|
+
try {
|
|
28
|
+
execFileSync('sudo', ['tee', envFile], { input: content, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
return { success: true, message: `System proxy set to ${proxyUrl}` };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return { success: false, message: `Failed: ${err}` };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Clear Linux system proxy */
|
|
38
|
+
export function clearSystemProxy(): { success: boolean; message: string } {
|
|
39
|
+
try {
|
|
40
|
+
try {
|
|
41
|
+
execFileSync('gsettings', ['set', 'org.gnome.system.proxy', 'mode', "'none'"], { stdio: 'pipe' });
|
|
42
|
+
} catch {}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
execFileSync('sudo', ['rm', '-f', '/etc/profile.d/smartcontext-proxy.sh'], { stdio: 'pipe' });
|
|
46
|
+
} catch {}
|
|
47
|
+
|
|
48
|
+
return { success: true, message: 'System proxy cleared' };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { success: false, message: `Failed: ${err}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isProxyConfigured(port: number): boolean {
|
|
55
|
+
const proxyEnv = process.env['https_proxy'] || process.env['HTTPS_PROXY'] || '';
|
|
56
|
+
return proxyEnv.includes(String(port));
|
|
57
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Get active network interface name (e.g., "Wi-Fi", "Ethernet") */
|
|
4
|
+
export function getActiveInterface(): string {
|
|
5
|
+
try {
|
|
6
|
+
const route = execFileSync('route', ['-n', 'get', 'default'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
7
|
+
const ifMatch = route.match(/interface:\s*(\S+)/);
|
|
8
|
+
if (!ifMatch) return 'Wi-Fi';
|
|
9
|
+
|
|
10
|
+
const ifName = ifMatch[1];
|
|
11
|
+
// Map interface ID to service name
|
|
12
|
+
const services = execFileSync('networksetup', ['-listallhardwareports'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
13
|
+
const lines = services.split('\n');
|
|
14
|
+
for (let i = 0; i < lines.length; i++) {
|
|
15
|
+
if (lines[i].includes(`Device: ${ifName}`)) {
|
|
16
|
+
const nameLine = lines[i - 1];
|
|
17
|
+
const nameMatch = nameLine?.match(/Hardware Port:\s*(.+)/);
|
|
18
|
+
if (nameMatch) return nameMatch[1];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return 'Wi-Fi';
|
|
22
|
+
} catch {
|
|
23
|
+
return 'Wi-Fi';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Configure macOS system proxy to use SmartContext */
|
|
28
|
+
export function setSystemProxy(host: string, port: number): { success: boolean; message: string } {
|
|
29
|
+
const iface = getActiveInterface();
|
|
30
|
+
try {
|
|
31
|
+
// Set HTTPS proxy
|
|
32
|
+
execFileSync('networksetup', ['-setsecurewebproxy', iface, host, String(port)], { stdio: 'pipe' });
|
|
33
|
+
// Set HTTP proxy
|
|
34
|
+
execFileSync('networksetup', ['-setwebproxy', iface, host, String(port)], { stdio: 'pipe' });
|
|
35
|
+
// Enable them
|
|
36
|
+
execFileSync('networksetup', ['-setsecurewebproxystate', iface, 'on'], { stdio: 'pipe' });
|
|
37
|
+
execFileSync('networksetup', ['-setwebproxystate', iface, 'on'], { stdio: 'pipe' });
|
|
38
|
+
|
|
39
|
+
return { success: true, message: `System proxy set to ${host}:${port} on ${iface}` };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { success: false, message: `Failed to set proxy on ${iface}: ${err}` };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Remove system proxy configuration */
|
|
46
|
+
export function clearSystemProxy(): { success: boolean; message: string } {
|
|
47
|
+
const iface = getActiveInterface();
|
|
48
|
+
try {
|
|
49
|
+
execFileSync('networksetup', ['-setsecurewebproxystate', iface, 'off'], { stdio: 'pipe' });
|
|
50
|
+
execFileSync('networksetup', ['-setwebproxystate', iface, 'off'], { stdio: 'pipe' });
|
|
51
|
+
return { success: true, message: `System proxy cleared on ${iface}` };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { success: false, message: `Failed to clear proxy on ${iface}: ${err}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Set PAC URL as auto-proxy configuration */
|
|
58
|
+
export function setAutoproxyURL(pacUrl: string): { success: boolean; message: string } {
|
|
59
|
+
const iface = getActiveInterface();
|
|
60
|
+
try {
|
|
61
|
+
execFileSync('networksetup', ['-setautoproxyurl', iface, pacUrl], { stdio: 'pipe' });
|
|
62
|
+
execFileSync('networksetup', ['-setautoproxystate', iface, 'on'], { stdio: 'pipe' });
|
|
63
|
+
return { success: true, message: `Auto-proxy URL set to ${pacUrl} on ${iface}` };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { success: false, message: `Failed: ${err}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Clear auto-proxy configuration */
|
|
70
|
+
export function clearAutoproxyURL(): { success: boolean; message: string } {
|
|
71
|
+
const iface = getActiveInterface();
|
|
72
|
+
try {
|
|
73
|
+
execFileSync('networksetup', ['-setautoproxystate', iface, 'off'], { stdio: 'pipe' });
|
|
74
|
+
return { success: true, message: `Auto-proxy cleared on ${iface}` };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { success: false, message: `Failed: ${err}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Check if system proxy is currently set to SmartContext */
|
|
81
|
+
export function isProxyConfigured(port: number): boolean {
|
|
82
|
+
const iface = getActiveInterface();
|
|
83
|
+
try {
|
|
84
|
+
const info = execFileSync('networksetup', ['-getsecurewebproxy', iface], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
85
|
+
return info.includes(`Port: ${port}`) && info.includes('Enabled: Yes');
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import * as macos from './macos.js';
|
|
3
|
+
import * as linux from './linux.js';
|
|
4
|
+
|
|
5
|
+
const CHECK_INTERVAL = 10_000; // 10 seconds
|
|
6
|
+
let watchdogTimer: NodeJS.Timeout | null = null;
|
|
7
|
+
let port = 4800;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Start watchdog that monitors proxy health.
|
|
11
|
+
* If proxy becomes unreachable, auto-removes system proxy config
|
|
12
|
+
* to prevent breaking the user's internet.
|
|
13
|
+
*/
|
|
14
|
+
export function startWatchdog(proxyPort: number): void {
|
|
15
|
+
port = proxyPort;
|
|
16
|
+
|
|
17
|
+
watchdogTimer = setInterval(() => {
|
|
18
|
+
checkHealth().catch(() => {
|
|
19
|
+
console.log('[watchdog] Proxy unreachable — clearing system proxy');
|
|
20
|
+
emergencyClearProxy();
|
|
21
|
+
stopWatchdog();
|
|
22
|
+
});
|
|
23
|
+
}, CHECK_INTERVAL);
|
|
24
|
+
|
|
25
|
+
// Also clear proxy on process exit
|
|
26
|
+
process.on('exit', emergencyClearProxy);
|
|
27
|
+
process.on('uncaughtException', (err) => {
|
|
28
|
+
console.error('[watchdog] Uncaught exception:', err);
|
|
29
|
+
emergencyClearProxy();
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
32
|
+
process.on('unhandledRejection', (err) => {
|
|
33
|
+
console.error('[watchdog] Unhandled rejection:', err);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function stopWatchdog(): void {
|
|
38
|
+
if (watchdogTimer) {
|
|
39
|
+
clearInterval(watchdogTimer);
|
|
40
|
+
watchdogTimer = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkHealth(): Promise<void> {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const req = http.get(`http://127.0.0.1:${port}/health`, { timeout: 5000 }, (res) => {
|
|
47
|
+
let data = '';
|
|
48
|
+
res.on('data', (chunk) => (data += chunk));
|
|
49
|
+
res.on('end', () => {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(data);
|
|
52
|
+
if (parsed.ok) resolve();
|
|
53
|
+
else reject(new Error('Health check failed'));
|
|
54
|
+
} catch {
|
|
55
|
+
reject(new Error('Invalid health response'));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
req.on('error', reject);
|
|
60
|
+
req.on('timeout', () => {
|
|
61
|
+
req.destroy();
|
|
62
|
+
reject(new Error('Health check timeout'));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emergencyClearProxy(): void {
|
|
68
|
+
try {
|
|
69
|
+
if (process.platform === 'darwin') {
|
|
70
|
+
macos.clearAutoproxyURL();
|
|
71
|
+
macos.clearSystemProxy();
|
|
72
|
+
} else {
|
|
73
|
+
linux.clearSystemProxy();
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import tls from 'node:tls';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { ConnectProxy } from '../proxy/connect-proxy.js';
|
|
9
|
+
import { buildConfig } from '../config/auto-detect.js';
|
|
10
|
+
import { ensureCA, getCACertPath } from '../tls/ca-manager.js';
|
|
11
|
+
import { classifyHost } from '../proxy/classifier.js';
|
|
12
|
+
|
|
13
|
+
function httpRequest(
|
|
14
|
+
url: string,
|
|
15
|
+
options: http.RequestOptions,
|
|
16
|
+
): Promise<{ status: number; body: string }> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const req = http.request(url, options, (res) => {
|
|
19
|
+
let data = '';
|
|
20
|
+
res.on('data', (chunk) => (data += chunk));
|
|
21
|
+
res.on('end', () => resolve({ status: res.statusCode || 0, body: data }));
|
|
22
|
+
});
|
|
23
|
+
req.on('error', reject);
|
|
24
|
+
req.end();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Traffic Classifier', () => {
|
|
29
|
+
it('identifies Anthropic as LLM', () => {
|
|
30
|
+
const match = classifyHost('api.anthropic.com', 443);
|
|
31
|
+
assert.ok(match);
|
|
32
|
+
assert.strictEqual(match.provider, 'anthropic');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('identifies OpenAI as LLM', () => {
|
|
36
|
+
const match = classifyHost('api.openai.com', 443);
|
|
37
|
+
assert.ok(match);
|
|
38
|
+
assert.strictEqual(match.provider, 'openai');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('identifies Ollama local as LLM', () => {
|
|
42
|
+
const match = classifyHost('localhost', 11434);
|
|
43
|
+
assert.ok(match);
|
|
44
|
+
assert.strictEqual(match.provider, 'ollama');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns null for non-LLM hosts', () => {
|
|
48
|
+
assert.strictEqual(classifyHost('google.com', 443), null);
|
|
49
|
+
assert.strictEqual(classifyHost('github.com', 443), null);
|
|
50
|
+
assert.strictEqual(classifyHost('localhost', 3000), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('identifies all known providers', () => {
|
|
54
|
+
const providers = [
|
|
55
|
+
['api.anthropic.com', 'anthropic'],
|
|
56
|
+
['api.openai.com', 'openai'],
|
|
57
|
+
['generativelanguage.googleapis.com', 'google'],
|
|
58
|
+
['openrouter.ai', 'openrouter'],
|
|
59
|
+
['api.groq.com', 'groq'],
|
|
60
|
+
['api.deepseek.com', 'deepseek'],
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
for (const [host, expected] of providers) {
|
|
64
|
+
const match = classifyHost(host, 443);
|
|
65
|
+
assert.ok(match, `Should match ${host}`);
|
|
66
|
+
assert.strictEqual(match.provider, expected);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('CA Manager', () => {
|
|
72
|
+
it('generates CA cert on first call', () => {
|
|
73
|
+
const result = ensureCA();
|
|
74
|
+
assert.ok(result.cert.includes('BEGIN CERTIFICATE'));
|
|
75
|
+
assert.ok(result.key.includes('BEGIN RSA PRIVATE KEY'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns same cert on subsequent calls', () => {
|
|
79
|
+
const a = ensureCA();
|
|
80
|
+
const b = ensureCA();
|
|
81
|
+
assert.strictEqual(a.cert, b.cert);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('CA cert file exists on disk', () => {
|
|
85
|
+
assert.ok(fs.existsSync(getCACertPath()));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('CONNECT Proxy', () => {
|
|
90
|
+
let proxy: ConnectProxy;
|
|
91
|
+
const PROXY_PORT = 14820;
|
|
92
|
+
|
|
93
|
+
before(async () => {
|
|
94
|
+
ensureCA();
|
|
95
|
+
const config = buildConfig({ proxy: { port: PROXY_PORT, host: '127.0.0.1' } });
|
|
96
|
+
config.logging.level = 'error';
|
|
97
|
+
proxy = new ConnectProxy(config);
|
|
98
|
+
await proxy.start();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
after(async () => {
|
|
102
|
+
await proxy.stop();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('serves dashboard at root', async () => {
|
|
106
|
+
const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/`, { method: 'GET' });
|
|
107
|
+
assert.strictEqual(res.status, 200);
|
|
108
|
+
assert.ok(res.body.includes('SmartContext'));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('serves health endpoint', async () => {
|
|
112
|
+
const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/health`, { method: 'GET' });
|
|
113
|
+
assert.strictEqual(res.status, 200);
|
|
114
|
+
const data = JSON.parse(res.body);
|
|
115
|
+
assert.strictEqual(data.ok, true);
|
|
116
|
+
assert.strictEqual(data.type, 'connect-proxy');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('serves PAC file', async () => {
|
|
120
|
+
const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/proxy.pac`, { method: 'GET' });
|
|
121
|
+
assert.strictEqual(res.status, 200);
|
|
122
|
+
assert.ok(res.body.includes('FindProxyForURL'));
|
|
123
|
+
assert.ok(res.body.includes('api.anthropic.com'));
|
|
124
|
+
assert.ok(res.body.includes('api.openai.com'));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('API endpoints work', async () => {
|
|
128
|
+
const status = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/_sc/status`, { method: 'GET' });
|
|
129
|
+
assert.strictEqual(JSON.parse(status.body).state, 'running');
|
|
130
|
+
|
|
131
|
+
const stats = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/_sc/stats`, { method: 'GET' });
|
|
132
|
+
assert.strictEqual(JSON.parse(stats.body).totalRequests, 0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles CONNECT to non-LLM host (tunnel)', async () => {
|
|
136
|
+
// Create a simple target server
|
|
137
|
+
const targetServer = http.createServer((req, res) => {
|
|
138
|
+
res.writeHead(200);
|
|
139
|
+
res.end('target-ok');
|
|
140
|
+
});
|
|
141
|
+
await new Promise<void>((r) => targetServer.listen(14821, '127.0.0.1', r));
|
|
142
|
+
|
|
143
|
+
// Send CONNECT through proxy
|
|
144
|
+
const tunnelOk = await new Promise<boolean>((resolve) => {
|
|
145
|
+
const req = http.request({
|
|
146
|
+
host: '127.0.0.1',
|
|
147
|
+
port: PROXY_PORT,
|
|
148
|
+
method: 'CONNECT',
|
|
149
|
+
path: '127.0.0.1:14821',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
req.on('connect', (res, socket) => {
|
|
153
|
+
// Send HTTP request through tunnel
|
|
154
|
+
socket.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n');
|
|
155
|
+
let data = '';
|
|
156
|
+
socket.on('data', (chunk) => { data += chunk; });
|
|
157
|
+
socket.on('end', () => {
|
|
158
|
+
socket.destroy();
|
|
159
|
+
resolve(data.includes('target-ok'));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
req.on('error', () => resolve(false));
|
|
164
|
+
req.end();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
targetServer.close();
|
|
168
|
+
assert.ok(tunnelOk, 'Should tunnel non-LLM traffic');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import forge from 'node-forge';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const CA_DIR = path.join(process.env['HOME'] || '.', '.smartcontext', 'ca');
|
|
6
|
+
const HOSTS_DIR = path.join(CA_DIR, 'hosts');
|
|
7
|
+
const CA_CERT_PATH = path.join(CA_DIR, 'smartcontext-ca.crt');
|
|
8
|
+
const CA_KEY_PATH = path.join(CA_DIR, 'smartcontext-ca.key');
|
|
9
|
+
|
|
10
|
+
export interface CertPair {
|
|
11
|
+
cert: string; // PEM
|
|
12
|
+
key: string; // PEM
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let cachedCA: { cert: forge.pki.Certificate; key: forge.pki.rsa.PrivateKey } | null = null;
|
|
16
|
+
const hostCertCache = new Map<string, CertPair>();
|
|
17
|
+
|
|
18
|
+
/** Ensure root CA exists, generate if not */
|
|
19
|
+
export function ensureCA(): CertPair {
|
|
20
|
+
fs.mkdirSync(CA_DIR, { recursive: true });
|
|
21
|
+
fs.mkdirSync(HOSTS_DIR, { recursive: true });
|
|
22
|
+
|
|
23
|
+
if (fs.existsSync(CA_CERT_PATH) && fs.existsSync(CA_KEY_PATH)) {
|
|
24
|
+
const cert = fs.readFileSync(CA_CERT_PATH, 'utf-8');
|
|
25
|
+
const key = fs.readFileSync(CA_KEY_PATH, 'utf-8');
|
|
26
|
+
cachedCA = {
|
|
27
|
+
cert: forge.pki.certificateFromPem(cert),
|
|
28
|
+
key: forge.pki.privateKeyFromPem(key) as forge.pki.rsa.PrivateKey,
|
|
29
|
+
};
|
|
30
|
+
return { cert, key };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Generate new root CA
|
|
34
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
35
|
+
const cert = forge.pki.createCertificate();
|
|
36
|
+
|
|
37
|
+
cert.publicKey = keys.publicKey;
|
|
38
|
+
cert.serialNumber = generateSerial();
|
|
39
|
+
cert.validity.notBefore = new Date();
|
|
40
|
+
cert.validity.notAfter = new Date();
|
|
41
|
+
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 10);
|
|
42
|
+
|
|
43
|
+
const attrs = [
|
|
44
|
+
{ name: 'commonName', value: 'SmartContext Proxy CA' },
|
|
45
|
+
{ name: 'organizationName', value: 'SmartContext' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
cert.setSubject(attrs);
|
|
49
|
+
cert.setIssuer(attrs);
|
|
50
|
+
|
|
51
|
+
cert.setExtensions([
|
|
52
|
+
{ name: 'basicConstraints', cA: true, critical: true },
|
|
53
|
+
{ name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
|
|
54
|
+
{ name: 'subjectKeyIdentifier' },
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
cert.sign(keys.privateKey, forge.md.sha256.create());
|
|
58
|
+
|
|
59
|
+
const certPem = forge.pki.certificateToPem(cert);
|
|
60
|
+
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
|
61
|
+
|
|
62
|
+
fs.writeFileSync(CA_CERT_PATH, certPem);
|
|
63
|
+
fs.writeFileSync(CA_KEY_PATH, keyPem, { mode: 0o600 });
|
|
64
|
+
|
|
65
|
+
cachedCA = { cert, key: keys.privateKey };
|
|
66
|
+
|
|
67
|
+
return { cert: certPem, key: keyPem };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Generate a TLS certificate for a specific hostname, signed by our CA */
|
|
71
|
+
export function getCertForHost(hostname: string): CertPair {
|
|
72
|
+
// Check memory cache
|
|
73
|
+
const cached = hostCertCache.get(hostname);
|
|
74
|
+
if (cached) return cached;
|
|
75
|
+
|
|
76
|
+
// Check disk cache
|
|
77
|
+
const certPath = path.join(HOSTS_DIR, `${hostname}.crt`);
|
|
78
|
+
const keyPath = path.join(HOSTS_DIR, `${hostname}.key`);
|
|
79
|
+
|
|
80
|
+
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|
81
|
+
const pair = {
|
|
82
|
+
cert: fs.readFileSync(certPath, 'utf-8'),
|
|
83
|
+
key: fs.readFileSync(keyPath, 'utf-8'),
|
|
84
|
+
};
|
|
85
|
+
hostCertCache.set(hostname, pair);
|
|
86
|
+
return pair;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Generate new cert
|
|
90
|
+
if (!cachedCA) ensureCA();
|
|
91
|
+
|
|
92
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
93
|
+
const cert = forge.pki.createCertificate();
|
|
94
|
+
|
|
95
|
+
cert.publicKey = keys.publicKey;
|
|
96
|
+
cert.serialNumber = generateSerial();
|
|
97
|
+
cert.validity.notBefore = new Date();
|
|
98
|
+
cert.validity.notAfter = new Date();
|
|
99
|
+
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
|
|
100
|
+
|
|
101
|
+
cert.setSubject([{ name: 'commonName', value: hostname }]);
|
|
102
|
+
cert.setIssuer(cachedCA!.cert.subject.attributes);
|
|
103
|
+
|
|
104
|
+
cert.setExtensions([
|
|
105
|
+
{ name: 'basicConstraints', cA: false },
|
|
106
|
+
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
|
|
107
|
+
{ name: 'extKeyUsage', serverAuth: true },
|
|
108
|
+
{
|
|
109
|
+
name: 'subjectAltName',
|
|
110
|
+
altNames: [
|
|
111
|
+
{ type: 2, value: hostname }, // DNS
|
|
112
|
+
...(isIP(hostname) ? [{ type: 7, ip: hostname }] : []),
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
cert.sign(cachedCA!.key, forge.md.sha256.create());
|
|
118
|
+
|
|
119
|
+
const certPem = forge.pki.certificateToPem(cert);
|
|
120
|
+
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(certPath, certPem);
|
|
123
|
+
fs.writeFileSync(keyPath, keyPem, { mode: 0o600 });
|
|
124
|
+
|
|
125
|
+
const pair = { cert: certPem, key: keyPem };
|
|
126
|
+
hostCertCache.set(hostname, pair);
|
|
127
|
+
return pair;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getCACertPath(): string {
|
|
131
|
+
return CA_CERT_PATH;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function generateSerial(): string {
|
|
135
|
+
return Date.now().toString(16) + Math.random().toString(16).slice(2, 10);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isIP(str: string): boolean {
|
|
139
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(str) || str.includes(':');
|
|
140
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getCACertPath } from './ca-manager.js';
|
|
5
|
+
|
|
6
|
+
const CA_NAME = 'SmartContext Proxy CA';
|
|
7
|
+
|
|
8
|
+
export interface TrustStoreResult {
|
|
9
|
+
success: boolean;
|
|
10
|
+
message: string;
|
|
11
|
+
requiresSudo: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Install CA cert into system trust store */
|
|
15
|
+
export function installCA(): TrustStoreResult {
|
|
16
|
+
const certPath = getCACertPath();
|
|
17
|
+
if (!fs.existsSync(certPath)) {
|
|
18
|
+
return { success: false, message: 'CA cert not found. Run ensureCA() first.', requiresSudo: false };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (process.platform === 'darwin') {
|
|
22
|
+
return installMacOS(certPath);
|
|
23
|
+
} else if (process.platform === 'linux') {
|
|
24
|
+
return installLinux(certPath);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { success: false, message: `Unsupported platform: ${process.platform}`, requiresSudo: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Remove CA cert from system trust store */
|
|
31
|
+
export function uninstallCA(): TrustStoreResult {
|
|
32
|
+
if (process.platform === 'darwin') {
|
|
33
|
+
return uninstallMacOS();
|
|
34
|
+
} else if (process.platform === 'linux') {
|
|
35
|
+
return uninstallLinux();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { success: false, message: `Unsupported platform: ${process.platform}`, requiresSudo: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Check if CA is installed in trust store */
|
|
42
|
+
export function isCAInstalled(): boolean {
|
|
43
|
+
if (process.platform === 'darwin') {
|
|
44
|
+
try {
|
|
45
|
+
const result = execFileSync('security', [
|
|
46
|
+
'find-certificate', '-c', CA_NAME, '-Z',
|
|
47
|
+
'/Library/Keychains/System.keychain',
|
|
48
|
+
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
49
|
+
return result.includes(CA_NAME);
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
} else if (process.platform === 'linux') {
|
|
54
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
55
|
+
return fs.existsSync(dest);
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function installMacOS(certPath: string): TrustStoreResult {
|
|
61
|
+
try {
|
|
62
|
+
// Add to system keychain (requires admin)
|
|
63
|
+
execFileSync('sudo', [
|
|
64
|
+
'security', 'add-trusted-cert',
|
|
65
|
+
'-d', '-r', 'trustRoot',
|
|
66
|
+
'-k', '/Library/Keychains/System.keychain',
|
|
67
|
+
certPath,
|
|
68
|
+
], { stdio: 'pipe' });
|
|
69
|
+
|
|
70
|
+
return { success: true, message: 'CA installed in macOS System Keychain', requiresSudo: true };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
73
|
+
if (msg.includes('sudo') || msg.includes('password')) {
|
|
74
|
+
return { success: false, message: 'Requires sudo. Run: sudo smartcontext-proxy install', requiresSudo: true };
|
|
75
|
+
}
|
|
76
|
+
return { success: false, message: `Failed to install CA: ${msg}`, requiresSudo: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function uninstallMacOS(): TrustStoreResult {
|
|
81
|
+
try {
|
|
82
|
+
execFileSync('sudo', [
|
|
83
|
+
'security', 'remove-trusted-cert',
|
|
84
|
+
'-d', getCACertPath(),
|
|
85
|
+
], { stdio: 'pipe' });
|
|
86
|
+
|
|
87
|
+
return { success: true, message: 'CA removed from macOS System Keychain', requiresSudo: true };
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Try alternative: delete by name
|
|
90
|
+
try {
|
|
91
|
+
execFileSync('sudo', [
|
|
92
|
+
'security', 'delete-certificate',
|
|
93
|
+
'-c', CA_NAME,
|
|
94
|
+
'/Library/Keychains/System.keychain',
|
|
95
|
+
], { stdio: 'pipe' });
|
|
96
|
+
return { success: true, message: 'CA removed from macOS System Keychain', requiresSudo: true };
|
|
97
|
+
} catch {
|
|
98
|
+
return { success: false, message: `Failed to remove CA: ${err}`, requiresSudo: true };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function installLinux(certPath: string): TrustStoreResult {
|
|
104
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('sudo', ['cp', certPath, dest], { stdio: 'pipe' });
|
|
107
|
+
execFileSync('sudo', ['update-ca-certificates'], { stdio: 'pipe' });
|
|
108
|
+
return { success: true, message: 'CA installed in Linux trust store', requiresSudo: true };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { success: false, message: `Failed: ${err}`, requiresSudo: true };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function uninstallLinux(): TrustStoreResult {
|
|
115
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
116
|
+
try {
|
|
117
|
+
execFileSync('sudo', ['rm', '-f', dest], { stdio: 'pipe' });
|
|
118
|
+
execFileSync('sudo', ['update-ca-certificates', '--fresh'], { stdio: 'pipe' });
|
|
119
|
+
return { success: true, message: 'CA removed from Linux trust store', requiresSudo: true };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { success: false, message: `Failed: ${err}`, requiresSudo: true };
|
|
122
|
+
}
|
|
123
|
+
}
|