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,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
|
|
@@ -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;
|
|
@@ -0,0 +1,121 @@
|
|
|
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.installCA = installCA;
|
|
7
|
+
exports.uninstallCA = uninstallCA;
|
|
8
|
+
exports.isCAInstalled = isCAInstalled;
|
|
9
|
+
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
11
|
+
const ca_manager_js_1 = require("./ca-manager.js");
|
|
12
|
+
const CA_NAME = 'SmartContext Proxy CA';
|
|
13
|
+
/** Install CA cert into system trust store */
|
|
14
|
+
function installCA() {
|
|
15
|
+
const certPath = (0, ca_manager_js_1.getCACertPath)();
|
|
16
|
+
if (!node_fs_1.default.existsSync(certPath)) {
|
|
17
|
+
return { success: false, message: 'CA cert not found. Run ensureCA() first.', requiresSudo: false };
|
|
18
|
+
}
|
|
19
|
+
if (process.platform === 'darwin') {
|
|
20
|
+
return installMacOS(certPath);
|
|
21
|
+
}
|
|
22
|
+
else if (process.platform === 'linux') {
|
|
23
|
+
return installLinux(certPath);
|
|
24
|
+
}
|
|
25
|
+
return { success: false, message: `Unsupported platform: ${process.platform}`, requiresSudo: false };
|
|
26
|
+
}
|
|
27
|
+
/** Remove CA cert from system trust store */
|
|
28
|
+
function uninstallCA() {
|
|
29
|
+
if (process.platform === 'darwin') {
|
|
30
|
+
return uninstallMacOS();
|
|
31
|
+
}
|
|
32
|
+
else if (process.platform === 'linux') {
|
|
33
|
+
return uninstallLinux();
|
|
34
|
+
}
|
|
35
|
+
return { success: false, message: `Unsupported platform: ${process.platform}`, requiresSudo: false };
|
|
36
|
+
}
|
|
37
|
+
/** Check if CA is installed in trust store */
|
|
38
|
+
function isCAInstalled() {
|
|
39
|
+
if (process.platform === 'darwin') {
|
|
40
|
+
try {
|
|
41
|
+
const result = (0, node_child_process_1.execFileSync)('security', [
|
|
42
|
+
'find-certificate', '-c', CA_NAME, '-Z',
|
|
43
|
+
'/Library/Keychains/System.keychain',
|
|
44
|
+
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
45
|
+
return result.includes(CA_NAME);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (process.platform === 'linux') {
|
|
52
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
53
|
+
return node_fs_1.default.existsSync(dest);
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function installMacOS(certPath) {
|
|
58
|
+
try {
|
|
59
|
+
// Add to system keychain (requires admin)
|
|
60
|
+
(0, node_child_process_1.execFileSync)('sudo', [
|
|
61
|
+
'security', 'add-trusted-cert',
|
|
62
|
+
'-d', '-r', 'trustRoot',
|
|
63
|
+
'-k', '/Library/Keychains/System.keychain',
|
|
64
|
+
certPath,
|
|
65
|
+
], { stdio: 'pipe' });
|
|
66
|
+
return { success: true, message: 'CA installed in macOS System Keychain', requiresSudo: true };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
70
|
+
if (msg.includes('sudo') || msg.includes('password')) {
|
|
71
|
+
return { success: false, message: 'Requires sudo. Run: sudo smartcontext-proxy install', requiresSudo: true };
|
|
72
|
+
}
|
|
73
|
+
return { success: false, message: `Failed to install CA: ${msg}`, requiresSudo: true };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function uninstallMacOS() {
|
|
77
|
+
try {
|
|
78
|
+
(0, node_child_process_1.execFileSync)('sudo', [
|
|
79
|
+
'security', 'remove-trusted-cert',
|
|
80
|
+
'-d', (0, ca_manager_js_1.getCACertPath)(),
|
|
81
|
+
], { stdio: 'pipe' });
|
|
82
|
+
return { success: true, message: 'CA removed from macOS System Keychain', requiresSudo: true };
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
// Try alternative: delete by name
|
|
86
|
+
try {
|
|
87
|
+
(0, node_child_process_1.execFileSync)('sudo', [
|
|
88
|
+
'security', 'delete-certificate',
|
|
89
|
+
'-c', CA_NAME,
|
|
90
|
+
'/Library/Keychains/System.keychain',
|
|
91
|
+
], { stdio: 'pipe' });
|
|
92
|
+
return { success: true, message: 'CA removed from macOS System Keychain', requiresSudo: true };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { success: false, message: `Failed to remove CA: ${err}`, requiresSudo: true };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function installLinux(certPath) {
|
|
100
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
101
|
+
try {
|
|
102
|
+
(0, node_child_process_1.execFileSync)('sudo', ['cp', certPath, dest], { stdio: 'pipe' });
|
|
103
|
+
(0, node_child_process_1.execFileSync)('sudo', ['update-ca-certificates'], { stdio: 'pipe' });
|
|
104
|
+
return { success: true, message: 'CA installed in Linux trust store', requiresSudo: true };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return { success: false, message: `Failed: ${err}`, requiresSudo: true };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function uninstallLinux() {
|
|
111
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
112
|
+
try {
|
|
113
|
+
(0, node_child_process_1.execFileSync)('sudo', ['rm', '-f', dest], { stdio: 'pipe' });
|
|
114
|
+
(0, node_child_process_1.execFileSync)('sudo', ['update-ca-certificates', '--fresh'], { stdio: 'pipe' });
|
|
115
|
+
return { success: true, message: 'CA removed from Linux trust store', requiresSudo: true };
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
return { success: false, message: `Failed: ${err}`, requiresSudo: true };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=trust-store.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Build the native tray app if not already built */
|
|
2
|
+
export declare function buildTray(): boolean;
|
|
3
|
+
/** Launch the tray app */
|
|
4
|
+
export declare function startTray(): boolean;
|
|
5
|
+
/** Stop the tray app */
|
|
6
|
+
export declare function stopTray(): void;
|
|
7
|
+
/** Check if tray is running */
|
|
8
|
+
export declare function isTrayRunning(): boolean;
|
|
@@ -0,0 +1,66 @@
|
|
|
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.buildTray = buildTray;
|
|
7
|
+
exports.startTray = startTray;
|
|
8
|
+
exports.stopTray = stopTray;
|
|
9
|
+
exports.isTrayRunning = isTrayRunning;
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
let trayProcess = null;
|
|
14
|
+
const TRAY_BINARY_DIR = node_path_1.default.join(__dirname, '..', '..', 'native', 'macos', 'SmartContextTray');
|
|
15
|
+
const TRAY_BUILD_DIR = node_path_1.default.join(TRAY_BINARY_DIR, '.build', 'release');
|
|
16
|
+
const TRAY_BINARY = node_path_1.default.join(TRAY_BUILD_DIR, 'SmartContextTray');
|
|
17
|
+
/** Build the native tray app if not already built */
|
|
18
|
+
function buildTray() {
|
|
19
|
+
if (process.platform !== 'darwin')
|
|
20
|
+
return false;
|
|
21
|
+
if (node_fs_1.default.existsSync(TRAY_BINARY))
|
|
22
|
+
return true;
|
|
23
|
+
try {
|
|
24
|
+
const { execFileSync } = require('node:child_process');
|
|
25
|
+
execFileSync('swift', ['build', '-c', 'release'], {
|
|
26
|
+
cwd: TRAY_BINARY_DIR,
|
|
27
|
+
stdio: 'pipe',
|
|
28
|
+
timeout: 60000,
|
|
29
|
+
});
|
|
30
|
+
return node_fs_1.default.existsSync(TRAY_BINARY);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.log(`Tray build failed: ${err}`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Launch the tray app */
|
|
38
|
+
function startTray() {
|
|
39
|
+
if (process.platform !== 'darwin')
|
|
40
|
+
return false;
|
|
41
|
+
if (trayProcess)
|
|
42
|
+
return true;
|
|
43
|
+
if (!buildTray()) {
|
|
44
|
+
console.log('Tray app not available (build failed or not macOS)');
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
trayProcess = (0, node_child_process_1.execFile)(TRAY_BINARY, [], {}, (err) => {
|
|
48
|
+
if (err)
|
|
49
|
+
console.log(`Tray exited: ${err.message}`);
|
|
50
|
+
trayProcess = null;
|
|
51
|
+
});
|
|
52
|
+
trayProcess.unref();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
/** Stop the tray app */
|
|
56
|
+
function stopTray() {
|
|
57
|
+
if (trayProcess) {
|
|
58
|
+
trayProcess.kill('SIGTERM');
|
|
59
|
+
trayProcess = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Check if tray is running */
|
|
63
|
+
function isTrayRunning() {
|
|
64
|
+
return trayProcess !== null && !trayProcess.killed;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=bridge.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
import type { RequestMetric } from '../metrics/collector.js';
|
|
3
|
+
/** Attach WebSocket server to existing HTTP server */
|
|
4
|
+
export declare function attachWebSocketFeed(server: Server): void;
|
|
5
|
+
/** Broadcast a new request metric to all connected clients */
|
|
6
|
+
export declare function broadcastMetric(metric: RequestMetric): void;
|
|
7
|
+
/** Get count of connected WebSocket clients */
|
|
8
|
+
export declare function getConnectedClients(): number;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.attachWebSocketFeed = attachWebSocketFeed;
|
|
4
|
+
exports.broadcastMetric = broadcastMetric;
|
|
5
|
+
exports.getConnectedClients = getConnectedClients;
|
|
6
|
+
const ws_1 = require("ws");
|
|
7
|
+
let wss = null;
|
|
8
|
+
/** Attach WebSocket server to existing HTTP server */
|
|
9
|
+
function attachWebSocketFeed(server) {
|
|
10
|
+
wss = new ws_1.WebSocketServer({ server, path: '/_sc/ws' });
|
|
11
|
+
wss.on('connection', (ws) => {
|
|
12
|
+
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Broadcast a new request metric to all connected clients */
|
|
16
|
+
function broadcastMetric(metric) {
|
|
17
|
+
if (!wss)
|
|
18
|
+
return;
|
|
19
|
+
const data = JSON.stringify({ type: 'request', data: metric });
|
|
20
|
+
for (const client of wss.clients) {
|
|
21
|
+
if (client.readyState === ws_1.WebSocket.OPEN) {
|
|
22
|
+
client.send(data);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Get count of connected WebSocket clients */
|
|
27
|
+
function getConnectedClients() {
|
|
28
|
+
return wss?.clients.size || 0;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=ws-feed.js.map
|