preflite 1.1.3 → 1.1.6
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/dist/mcp/server.js +0 -203
- package/dist/mcp/setup.js +144 -12
- package/dist/mcp/visual-flow/validate.js +0 -171
- package/docs/visual-flow-ir-llm.md +0 -68
- package/package.json +1 -1
- package/dist/mcp/network-mocks/NetworkMockServer.js +0 -579
- package/dist/mcp/network-mocks/NetworkMockService.js +0 -156
- package/dist/mcp/network-mocks/device-proxy.js +0 -31
- package/dist/mcp/network-mocks/index.js +0 -3
- package/dist/mcp/network-mocks/types.js +0 -1
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
| ------------ | --- | ------------------------------------ |
|
|
12
12
|
| `version` | 是 | 固定为数字 `2`,其它值保存失败。 |
|
|
13
13
|
| `scriptVars` | 否 | 执行前由人填的变量声明数组;步骤文案里用 `{{变量名}}` 引用。 |
|
|
14
|
-
| `networkMocks` | 否 | 网络 mock 规则数组,在测试开始前启动代理并配置到设备。每条规则描述一个 API 的 URL 匹配与 mock 响应序列。 |
|
|
15
14
|
| `steps` | 是 | 顶层步骤数组,按顺序执行;**展开后总条数 ≤ 500**(含子步骤)。 |
|
|
16
15
|
|
|
17
16
|
|
|
@@ -26,70 +25,6 @@
|
|
|
26
25
|
| `scope` | 否 | `global`、`local` 或 `temp`;缺省按平台约定。 |
|
|
27
26
|
|
|
28
27
|
|
|
29
|
-
### 1.2 `networkMocks[]` 每项
|
|
30
|
-
|
|
31
|
-
| 字段 | 必填 | 说明 |
|
|
32
|
-
| -------------- | --- | -------------------------------------- |
|
|
33
|
-
| `urlPattern` | 是 | 请求 URL 的子串匹配(`includes` 语义)。首个匹配的规则生效。 |
|
|
34
|
-
| `method` | 否 | HTTP 方法:`GET`、`POST`、`PUT`、`DELETE` 或 `PATCH`。不填则匹配任意方法。 |
|
|
35
|
-
| `responses` | 是 | 非空响应序列数组,按顺序匹配第一项满足条件的响应。 |
|
|
36
|
-
| `description` | 否 | 给人看的说明。 |
|
|
37
|
-
|
|
38
|
-
#### `responses[]` 每项
|
|
39
|
-
|
|
40
|
-
| 字段 | 必填 | 说明 |
|
|
41
|
-
| ------------------ | --- | -------------------------------------- |
|
|
42
|
-
| `body` | 是 | 响应体字符串(通常为 JSON)。上限 1MB。 |
|
|
43
|
-
| `status` | 否 | HTTP 状态码,默认 200。范围 100~599。 |
|
|
44
|
-
| `callIndex` | 否 | 仅第 n 次调用时匹配(1-based)。用于实现状态性 mock:首次返回错误,二次返回成功。 |
|
|
45
|
-
| `requestBodyMatch` | 否 | 键值对映射,必须在请求体的 JSON 中存在且值匹配才会命中。 |
|
|
46
|
-
| `headers` | 否 | 额外的响应头。Content-Type 默认为 `application/json; charset=utf-8`。 |
|
|
47
|
-
| `delay` | 否 | 响应延迟毫秒数。范围 0~60000。 |
|
|
48
|
-
|
|
49
|
-
#### 匹配规则
|
|
50
|
-
|
|
51
|
-
1. 遍历 `networkMocks`,找到第一条 `urlPattern` 是请求 URL 子串的规则。
|
|
52
|
-
2. 递增该规则的调用计数器。
|
|
53
|
-
3. 按顺序遍历 `responses`,找到第一项满足 `callIndex` 和 `requestBodyMatch` 的响应并返回。
|
|
54
|
-
4. 若无匹配响应或请求不匹配任何规则,透明转发到真实服务器。
|
|
55
|
-
|
|
56
|
-
#### 示例
|
|
57
|
-
|
|
58
|
-
```json
|
|
59
|
-
{
|
|
60
|
-
"networkMocks": [
|
|
61
|
-
{
|
|
62
|
-
"urlPattern": "getMafangRosterNewFlowSwitch",
|
|
63
|
-
"description": "启用码放新流程",
|
|
64
|
-
"responses": [
|
|
65
|
-
{
|
|
66
|
-
"status": 200,
|
|
67
|
-
"body": "{\"code\":200,\"data\":{\"newFlowEnabled\":true},\"subcode\":200}"
|
|
68
|
-
}
|
|
69
|
-
]
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
"urlPattern": "copyWorkScheduleGroup",
|
|
73
|
-
"method": "POST",
|
|
74
|
-
"description": "日复制先弹确认再成功",
|
|
75
|
-
"responses": [
|
|
76
|
-
{
|
|
77
|
-
"callIndex": 1,
|
|
78
|
-
"status": 200,
|
|
79
|
-
"body": "{\"code\":\"WORK_SCHEDULE_PARTITION_MAFANG_COPY_SKIP_CONFIRM\",\"data\":{\"blockedCount\":2}}"
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
"callIndex": 2,
|
|
83
|
-
"requestBodyMatch": {"mafangSkipConfirmed": "true"},
|
|
84
|
-
"status": 200,
|
|
85
|
-
"body": "{\"code\":200,\"data\":{\"flag\":true}}"
|
|
86
|
-
}
|
|
87
|
-
]
|
|
88
|
-
}
|
|
89
|
-
]
|
|
90
|
-
}
|
|
91
|
-
```
|
|
92
|
-
|
|
93
28
|
---
|
|
94
29
|
|
|
95
30
|
## 2. 步骤类型与设计原则
|
|
@@ -166,9 +101,6 @@
|
|
|
166
101
|
## 4. 易错校验(生成后自查)
|
|
167
102
|
|
|
168
103
|
- `version !== 2` → 失败。
|
|
169
|
-
- `networkMocks` 超过 50 条规则或单条规则的 `responses` 超过 50 项 → 失败。
|
|
170
|
-
- `networkMocks[].urlPattern` 为空或过长(>1000)→ 失败。
|
|
171
|
-
- `networkMocks[].responses` 为空数组 → 失败。
|
|
172
104
|
- `if` / `ifDeviceType` 的 `thenSteps` 为空,或 `whileLoop` / `forLoop` 的 `bodySteps` 为空 → 失败。
|
|
173
105
|
- `sleep.ms`、`whileLoop.maxIterations`、`forLoop.count` 超出上表范围 → 失败。
|
|
174
106
|
- `callScript.targetTestCaseId` 非 24 位 hex,或 `scopeId` 不符合 `sub`+12hex → 失败。
|
package/package.json
CHANGED
|
@@ -1,579 +0,0 @@
|
|
|
1
|
-
import { createServer, request as httpRequest } from "node:http";
|
|
2
|
-
import { request as httpsRequest } from "node:https";
|
|
3
|
-
import { connect as netConnect } from "node:net";
|
|
4
|
-
import tls from "node:tls";
|
|
5
|
-
import { TLSSocket } from "node:tls";
|
|
6
|
-
import { execSync } from "node:child_process";
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { writeFileSync, unlinkSync, readFileSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
function findJsonValue(obj, key) {
|
|
12
|
-
if (!obj || typeof obj !== "object")
|
|
13
|
-
return undefined;
|
|
14
|
-
if (key in obj)
|
|
15
|
-
return obj[key];
|
|
16
|
-
for (const v of Object.values(obj)) {
|
|
17
|
-
const result = findJsonValue(v, key);
|
|
18
|
-
if (result !== undefined)
|
|
19
|
-
return result;
|
|
20
|
-
}
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
function getRuleKey(rule) { return (rule.urlPattern ?? rule.urlRegex ?? "") ?? rule.urlRegex ?? ""; }
|
|
24
|
-
export class NetworkMockServer {
|
|
25
|
-
server = null;
|
|
26
|
-
rules = [];
|
|
27
|
-
callCounts = new Map();
|
|
28
|
-
port = 0;
|
|
29
|
-
rootCA = null;
|
|
30
|
-
certCache = new Map();
|
|
31
|
-
mitmHttpServer = null;
|
|
32
|
-
recording = false;
|
|
33
|
-
recorded = [];
|
|
34
|
-
start(rules, bindAddress = "0.0.0.0", preferredPort = 0) {
|
|
35
|
-
this.rules = rules;
|
|
36
|
-
this.callCounts.clear();
|
|
37
|
-
this.certCache.clear();
|
|
38
|
-
this.rootCA = this.loadOrGenerateRootCA();
|
|
39
|
-
for (const rule of rules) {
|
|
40
|
-
this.callCounts.set((rule.urlPattern ?? rule.urlRegex ?? ""), 0);
|
|
41
|
-
}
|
|
42
|
-
this.mitmHttpServer = createServer((req, res) => {
|
|
43
|
-
// hostname/port are stored on the socket during handleConnectEvent
|
|
44
|
-
const sock = req.socket;
|
|
45
|
-
void this.handleMitmRequest(req, res, sock.__mitmHostname ?? "unknown", sock.__mitmPort ?? 443);
|
|
46
|
-
});
|
|
47
|
-
this.mitmHttpServer.on("error", () => { });
|
|
48
|
-
return new Promise((resolve, reject) => {
|
|
49
|
-
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
50
|
-
this.server.on("connect", (req, socket, head) => this.handleConnectEvent(req, socket, head));
|
|
51
|
-
this.server.on("error", reject);
|
|
52
|
-
this.server.listen(preferredPort, bindAddress, () => {
|
|
53
|
-
const addr = this.server.address();
|
|
54
|
-
if (!addr || typeof addr === "string") {
|
|
55
|
-
reject(new Error("Failed to get server address"));
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
this.port = addr.port;
|
|
59
|
-
resolve(this.port);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
stop() {
|
|
64
|
-
this.certCache.clear();
|
|
65
|
-
return new Promise((resolve) => {
|
|
66
|
-
if (this.mitmHttpServer) {
|
|
67
|
-
this.mitmHttpServer.close();
|
|
68
|
-
this.mitmHttpServer = null;
|
|
69
|
-
}
|
|
70
|
-
if (!this.server)
|
|
71
|
-
return resolve();
|
|
72
|
-
this.server.close(() => { this.server = null; this.port = 0; resolve(); });
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
getPort() { return this.port; }
|
|
76
|
-
getRootCACert() { return this.rootCA?.cert ?? null; }
|
|
77
|
-
/** Generate an iOS .mobileconfig profile that installs the CA cert + proxy settings. */
|
|
78
|
-
generateMobileConfig(proxyHost, proxyPort) {
|
|
79
|
-
if (!this.rootCA)
|
|
80
|
-
return null;
|
|
81
|
-
const payloadContent = this.rootCA.cert
|
|
82
|
-
.replace(/-----BEGIN CERTIFICATE-----/, "")
|
|
83
|
-
.replace(/-----END CERTIFICATE-----/, "")
|
|
84
|
-
.replace(/\n/g, "");
|
|
85
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
86
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
87
|
-
<plist version="1.0">
|
|
88
|
-
<dict>
|
|
89
|
-
<key>PayloadContent</key>
|
|
90
|
-
<array>
|
|
91
|
-
<dict>
|
|
92
|
-
<key>PayloadCertificateFileName</key>
|
|
93
|
-
<string>Preflight Mock CA</string>
|
|
94
|
-
<key>PayloadContent</key>
|
|
95
|
-
<data>${payloadContent}</data>
|
|
96
|
-
<key>PayloadDescription</key>
|
|
97
|
-
<string>信任此证书以启用 Preflight HTTPS 拦截</string>
|
|
98
|
-
<key>PayloadDisplayName</key>
|
|
99
|
-
<string>Preflight Mock CA</string>
|
|
100
|
-
<key>PayloadIdentifier</key>
|
|
101
|
-
<string>com.preflight.ca</string>
|
|
102
|
-
<key>PayloadType</key>
|
|
103
|
-
<string>com.apple.security.root</string>
|
|
104
|
-
<key>PayloadUUID</key>
|
|
105
|
-
<string>${randomUUID().toUpperCase()}</string>
|
|
106
|
-
<key>PayloadVersion</key>
|
|
107
|
-
<integer>1</integer>
|
|
108
|
-
</dict>
|
|
109
|
-
<dict>
|
|
110
|
-
<key>PayloadContent</key>
|
|
111
|
-
<dict>
|
|
112
|
-
<key>HTTPProxy</key>
|
|
113
|
-
<string>${proxyHost}</string>
|
|
114
|
-
<key>HTTPPort</key>
|
|
115
|
-
<integer>${proxyPort}</integer>
|
|
116
|
-
<key>HTTPSProxy</key>
|
|
117
|
-
<string>${proxyHost}</string>
|
|
118
|
-
<key>HTTPSPort</key>
|
|
119
|
-
<integer>${proxyPort}</integer>
|
|
120
|
-
<key>ProxyAutoConfigEnable</key>
|
|
121
|
-
<false/>
|
|
122
|
-
<key>ProxyAutoDiscoveryEnable</key>
|
|
123
|
-
<false/>
|
|
124
|
-
</dict>
|
|
125
|
-
<key>PayloadDescription</key>
|
|
126
|
-
<string>配置 WiFi 代理到 Preflight Mock Server</string>
|
|
127
|
-
<key>PayloadDisplayName</key>
|
|
128
|
-
<string>WiFi Proxy (Preflight)</string>
|
|
129
|
-
<key>PayloadIdentifier</key>
|
|
130
|
-
<string>com.preflight.proxy</string>
|
|
131
|
-
<key>PayloadType</key>
|
|
132
|
-
<string>com.apple.SystemConfiguration</string>
|
|
133
|
-
<key>PayloadUUID</key>
|
|
134
|
-
<string>${randomUUID().toUpperCase()}</string>
|
|
135
|
-
<key>PayloadVersion</key>
|
|
136
|
-
<integer>1</integer>
|
|
137
|
-
</dict>
|
|
138
|
-
</array>
|
|
139
|
-
<key>PayloadDescription</key>
|
|
140
|
-
<string>安装 Preflight Mock CA 证书和 WiFi 代理配置。安装后前往 Settings > General > About > Certificate Trust Settings 开启信任。</string>
|
|
141
|
-
<key>PayloadDisplayName</key>
|
|
142
|
-
<string>Preflight Network Mock</string>
|
|
143
|
-
<key>PayloadIdentifier</key>
|
|
144
|
-
<string>com.preflight.mocks</string>
|
|
145
|
-
<key>PayloadOrganization</key>
|
|
146
|
-
<string>Preflight</string>
|
|
147
|
-
<key>PayloadType</key>
|
|
148
|
-
<string>Configuration</string>
|
|
149
|
-
<key>PayloadUUID</key>
|
|
150
|
-
<string>${randomUUID().toUpperCase()}</string>
|
|
151
|
-
<key>PayloadVersion</key>
|
|
152
|
-
<integer>1</integer>
|
|
153
|
-
</dict>
|
|
154
|
-
</plist>`;
|
|
155
|
-
}
|
|
156
|
-
getStats() {
|
|
157
|
-
return {
|
|
158
|
-
running: this.server !== null,
|
|
159
|
-
port: this.port,
|
|
160
|
-
mitmEnabled: this.rootCA !== null,
|
|
161
|
-
rules: this.rules.map((rule) => ({
|
|
162
|
-
urlPattern: (rule.urlPattern ?? rule.urlRegex ?? ""),
|
|
163
|
-
description: rule.description,
|
|
164
|
-
callCount: this.callCounts.get((rule.urlPattern ?? rule.urlRegex ?? "")) ?? 0,
|
|
165
|
-
})),
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
updateRules(rules) {
|
|
169
|
-
this.rules = rules;
|
|
170
|
-
this.callCounts.clear();
|
|
171
|
-
for (const rule of rules) {
|
|
172
|
-
this.callCounts.set((rule.urlPattern ?? rule.urlRegex ?? ""), 0);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
setRecording(enabled) {
|
|
176
|
-
this.recording = enabled;
|
|
177
|
-
if (enabled)
|
|
178
|
-
this.recorded = [];
|
|
179
|
-
}
|
|
180
|
-
clearRecording() { this.recorded = []; }
|
|
181
|
-
isRecording() { return this.recording; }
|
|
182
|
-
getRecordedCount() { return this.recorded.length; }
|
|
183
|
-
exportRecordedRules() {
|
|
184
|
-
const dedup = new Map();
|
|
185
|
-
for (const r of this.recorded) {
|
|
186
|
-
const key = r.url;
|
|
187
|
-
let entry = dedup.get(key);
|
|
188
|
-
if (!entry) {
|
|
189
|
-
entry = { url: r.url, method: r.method !== "GET" ? r.method : undefined, requestBodies: new Map() };
|
|
190
|
-
dedup.set(key, entry);
|
|
191
|
-
}
|
|
192
|
-
const bodyKey = r.requestBody || "(empty)";
|
|
193
|
-
entry.requestBodies.set(bodyKey, (entry.requestBodies.get(bodyKey) ?? 0) + 1);
|
|
194
|
-
}
|
|
195
|
-
const rules = [];
|
|
196
|
-
for (const entry of dedup.values()) {
|
|
197
|
-
const responses = this.recorded
|
|
198
|
-
.filter((r) => r.url === entry.url)
|
|
199
|
-
.map((r, i) => {
|
|
200
|
-
const resp = {
|
|
201
|
-
status: r.status,
|
|
202
|
-
body: r.responseBody.slice(0, 500_000),
|
|
203
|
-
};
|
|
204
|
-
if (i > 0)
|
|
205
|
-
resp.callIndex = i + 1;
|
|
206
|
-
if (r.requestBody) {
|
|
207
|
-
try {
|
|
208
|
-
const parsed = JSON.parse(r.requestBody);
|
|
209
|
-
const flat = {};
|
|
210
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
211
|
-
if (typeof v === "string")
|
|
212
|
-
flat[k] = v;
|
|
213
|
-
else if (typeof v === "number" || typeof v === "boolean")
|
|
214
|
-
flat[k] = String(v);
|
|
215
|
-
}
|
|
216
|
-
if (Object.keys(flat).length > 0)
|
|
217
|
-
resp.requestBodyMatch = flat;
|
|
218
|
-
}
|
|
219
|
-
catch { /* ignore */ }
|
|
220
|
-
}
|
|
221
|
-
return resp;
|
|
222
|
-
});
|
|
223
|
-
rules.push({
|
|
224
|
-
urlPattern: entry.url,
|
|
225
|
-
...(entry.method ? { method: entry.method } : {}),
|
|
226
|
-
responses: responses.slice(0, 50),
|
|
227
|
-
description: "recorded",
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
return rules;
|
|
231
|
-
}
|
|
232
|
-
// ── HTTP proxy handler ──
|
|
233
|
-
handleRequest(clientReq, clientRes) {
|
|
234
|
-
if (clientReq.method === "CONNECT")
|
|
235
|
-
return;
|
|
236
|
-
const requestUrl = this.resolveUrl(clientReq);
|
|
237
|
-
if (!requestUrl) {
|
|
238
|
-
clientRes.writeHead(400);
|
|
239
|
-
clientRes.end();
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const match = this.findMatch(clientReq.method ?? "GET", requestUrl);
|
|
243
|
-
match ? this.serveMock(match, clientRes) : this.forwardRequest(clientReq, clientRes, requestUrl);
|
|
244
|
-
}
|
|
245
|
-
resolveUrl(req) {
|
|
246
|
-
if (req.url && (req.url.startsWith("http://") || req.url.startsWith("https://"))) {
|
|
247
|
-
try {
|
|
248
|
-
return new URL(req.url);
|
|
249
|
-
}
|
|
250
|
-
catch {
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
if (req.headers.host && req.url) {
|
|
255
|
-
try {
|
|
256
|
-
return new URL(req.url, `http://${req.headers.host}`);
|
|
257
|
-
}
|
|
258
|
-
catch {
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
findMatch(method, url, reqBody) {
|
|
265
|
-
const urlStr = url.toString();
|
|
266
|
-
for (const rule of this.rules) {
|
|
267
|
-
if (rule.urlPattern && !urlStr.includes(rule.urlPattern))
|
|
268
|
-
continue;
|
|
269
|
-
if (rule.urlRegex && !new RegExp(rule.urlRegex).test(urlStr))
|
|
270
|
-
continue;
|
|
271
|
-
if (!rule.urlPattern && !rule.urlRegex)
|
|
272
|
-
continue;
|
|
273
|
-
if (rule.method && rule.method.toUpperCase() !== method.toUpperCase())
|
|
274
|
-
continue;
|
|
275
|
-
if (rule.queryParams) {
|
|
276
|
-
let qm = true;
|
|
277
|
-
for (const [k, v] of Object.entries(rule.queryParams)) {
|
|
278
|
-
if (url.searchParams.get(k) !== v) {
|
|
279
|
-
qm = false;
|
|
280
|
-
break;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
if (!qm)
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
const currentCount = (this.callCounts.get((rule.urlPattern ?? rule.urlRegex ?? "")) ?? 0) + 1;
|
|
287
|
-
this.callCounts.set((rule.urlPattern ?? rule.urlRegex ?? ""), currentCount);
|
|
288
|
-
for (const resp of rule.responses) {
|
|
289
|
-
if (resp.callIndex != null && resp.callIndex !== currentCount)
|
|
290
|
-
continue;
|
|
291
|
-
if (resp.requestBodyMatch && reqBody) {
|
|
292
|
-
for (const [key, expected] of Object.entries(resp.requestBodyMatch)) {
|
|
293
|
-
const actual = findJsonValue(reqBody, key);
|
|
294
|
-
if (actual === undefined || String(actual) !== expected)
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return resp;
|
|
299
|
-
}
|
|
300
|
-
return null;
|
|
301
|
-
}
|
|
302
|
-
return null;
|
|
303
|
-
}
|
|
304
|
-
serveMock(mock, res) {
|
|
305
|
-
const { status = 200, body, headers = {}, delay = 0 } = mock;
|
|
306
|
-
const send = () => {
|
|
307
|
-
res.writeHead(status, {
|
|
308
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
309
|
-
"Access-Control-Allow-Origin": "*",
|
|
310
|
-
"X-Preflight-Mock": "true",
|
|
311
|
-
...headers,
|
|
312
|
-
});
|
|
313
|
-
res.end(body);
|
|
314
|
-
};
|
|
315
|
-
delay > 0 ? setTimeout(send, delay) : send();
|
|
316
|
-
}
|
|
317
|
-
forwardRequest(clientReq, clientRes, url) {
|
|
318
|
-
const opts = {
|
|
319
|
-
hostname: url.hostname, port: url.port || 80,
|
|
320
|
-
path: url.pathname + url.search, method: clientReq.method,
|
|
321
|
-
headers: { ...clientReq.headers },
|
|
322
|
-
};
|
|
323
|
-
delete opts.headers["proxy-connection"];
|
|
324
|
-
const pr = httpRequest(opts, (pres) => { clientRes.writeHead(pres.statusCode ?? 502, pres.headers); pres.pipe(clientRes); });
|
|
325
|
-
pr.on("error", () => { if (!clientRes.headersSent) {
|
|
326
|
-
clientRes.writeHead(502);
|
|
327
|
-
clientRes.end("Bad Gateway");
|
|
328
|
-
} });
|
|
329
|
-
clientReq.pipe(pr);
|
|
330
|
-
}
|
|
331
|
-
// ── HTTPS MITM handler ──
|
|
332
|
-
handleConnectEvent(req, socket, _head) {
|
|
333
|
-
const sock = socket;
|
|
334
|
-
const [hostname, portStr] = (req.url ?? "").split(":");
|
|
335
|
-
const port = Number.parseInt(portStr, 10) || 443;
|
|
336
|
-
if (!hostname) {
|
|
337
|
-
sock.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
338
|
-
sock.destroy();
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
// Skip MITM for Apple captive-portal detection & connectivity checks
|
|
342
|
-
if (this.shouldSkipMitm(hostname)) {
|
|
343
|
-
this.tunnelConnect(sock, hostname, port);
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
const secCtx = this.getServerSecureContext(hostname);
|
|
347
|
-
if (!secCtx) {
|
|
348
|
-
this.tunnelConnect(sock, hostname, port);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
352
|
-
sock.__mitmHostname = hostname;
|
|
353
|
-
sock.__mitmPort = port;
|
|
354
|
-
const tlsSocket = new TLSSocket(sock, {
|
|
355
|
-
isServer: true,
|
|
356
|
-
secureContext: secCtx,
|
|
357
|
-
ALPNProtocols: ["http/1.1"],
|
|
358
|
-
});
|
|
359
|
-
tlsSocket.__mitmHostname = hostname;
|
|
360
|
-
tlsSocket.__mitmPort = port;
|
|
361
|
-
tlsSocket.on("error", () => { if (!tlsSocket.destroyed)
|
|
362
|
-
tlsSocket.destroy(); });
|
|
363
|
-
// Route through shared HTTP server
|
|
364
|
-
this.mitmHttpServer.emit("connection", tlsSocket);
|
|
365
|
-
}
|
|
366
|
-
async handleMitmRequest(innerReq, innerRes, hostname, port) {
|
|
367
|
-
const fullUrl = `https://${hostname}${innerReq.url ?? "/"}`;
|
|
368
|
-
let url;
|
|
369
|
-
try {
|
|
370
|
-
url = new URL(fullUrl);
|
|
371
|
-
}
|
|
372
|
-
catch {
|
|
373
|
-
innerRes.writeHead(400);
|
|
374
|
-
innerRes.end();
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
const match = this.findMatch(innerReq.method ?? "GET", url);
|
|
378
|
-
if (match) {
|
|
379
|
-
if (this.recording)
|
|
380
|
-
this.recordMatched(url.toString(), innerReq.method ?? "GET", match);
|
|
381
|
-
this.serveMock(match, innerRes);
|
|
382
|
-
}
|
|
383
|
-
else {
|
|
384
|
-
if (this.recording)
|
|
385
|
-
this.recordForwarded(url.toString(), innerReq.method ?? "GET", innerReq, innerRes);
|
|
386
|
-
this.forwardHttpsRequest(innerReq, innerRes, hostname, port);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
recordMatched(urlStr, method, mock) {
|
|
390
|
-
if (this.recorded.length >= 1000)
|
|
391
|
-
return;
|
|
392
|
-
this.recorded.push({
|
|
393
|
-
url: urlStr,
|
|
394
|
-
method,
|
|
395
|
-
requestBody: "",
|
|
396
|
-
responseBody: mock.body,
|
|
397
|
-
status: mock.status ?? 200,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
recordForwarded(urlStr, method, req, res) {
|
|
401
|
-
if (this.recorded.length >= 1000)
|
|
402
|
-
return;
|
|
403
|
-
let reqBody = "";
|
|
404
|
-
req.on("data", (chunk) => { reqBody += chunk.toString(); });
|
|
405
|
-
const origWriteHead = res.writeHead.bind(res);
|
|
406
|
-
let status = 0;
|
|
407
|
-
res.writeHead = function (code, ...args) {
|
|
408
|
-
status = code;
|
|
409
|
-
return origWriteHead(code, ...args);
|
|
410
|
-
};
|
|
411
|
-
const self = this;
|
|
412
|
-
const origEnd = res.end.bind(res);
|
|
413
|
-
res.end = function (chunk, ...args) {
|
|
414
|
-
const respBody = chunk ? (typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()) : "";
|
|
415
|
-
if (status > 0) {
|
|
416
|
-
self.recorded.push({ url: urlStr, method, requestBody: reqBody.slice(0, 10000), responseBody: respBody.slice(0, 10000), status });
|
|
417
|
-
}
|
|
418
|
-
return origEnd(chunk, ...args);
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
forwardHttpsRequest(clientReq, clientRes, hostname, port) {
|
|
422
|
-
const opts = {
|
|
423
|
-
hostname, port, path: clientReq.url, method: clientReq.method,
|
|
424
|
-
headers: { ...clientReq.headers }, rejectUnauthorized: false,
|
|
425
|
-
};
|
|
426
|
-
delete opts.headers["proxy-connection"];
|
|
427
|
-
delete opts.headers["proxy-authorization"];
|
|
428
|
-
const pr = httpsRequest(opts, (pres) => { clientRes.writeHead(pres.statusCode ?? 502, pres.headers); pres.pipe(clientRes); });
|
|
429
|
-
pr.on("error", () => { if (!clientRes.headersSent) {
|
|
430
|
-
clientRes.writeHead(502);
|
|
431
|
-
clientRes.end("Bad Gateway");
|
|
432
|
-
} });
|
|
433
|
-
clientReq.pipe(pr);
|
|
434
|
-
}
|
|
435
|
-
tunnelConnect(sock, hostname, port) {
|
|
436
|
-
let connected = false;
|
|
437
|
-
const upstream = netConnect({ host: hostname, port }, () => {
|
|
438
|
-
connected = true;
|
|
439
|
-
sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
440
|
-
upstream.pipe(sock);
|
|
441
|
-
sock.pipe(upstream);
|
|
442
|
-
});
|
|
443
|
-
upstream.on("error", () => {
|
|
444
|
-
if (!connected)
|
|
445
|
-
sock.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
446
|
-
upstream.destroy();
|
|
447
|
-
sock.destroy();
|
|
448
|
-
});
|
|
449
|
-
sock.on("error", () => upstream.destroy());
|
|
450
|
-
setTimeout(() => {
|
|
451
|
-
if (!connected) {
|
|
452
|
-
sock.write("HTTP/1.1 504 Gateway Timeout\r\n\r\n");
|
|
453
|
-
upstream.destroy();
|
|
454
|
-
sock.destroy();
|
|
455
|
-
}
|
|
456
|
-
}, 30_000);
|
|
457
|
-
}
|
|
458
|
-
shouldSkipMitm(hostname) {
|
|
459
|
-
// Apple captive portal detection and connectivity checks — must passthrough
|
|
460
|
-
const passthroughSuffixes = [
|
|
461
|
-
".apple.com",
|
|
462
|
-
".icloud.com",
|
|
463
|
-
"captive.apple.com",
|
|
464
|
-
"gsp10-ssl.apple.com",
|
|
465
|
-
"gsp11-ssl.apple.com",
|
|
466
|
-
"gsp12-ssl.apple.com",
|
|
467
|
-
"gsp13-ssl.apple.com",
|
|
468
|
-
];
|
|
469
|
-
return passthroughSuffixes.some((s) => hostname === s || hostname.endsWith(s));
|
|
470
|
-
}
|
|
471
|
-
// ── Certificate generation (openssl CLI) ──
|
|
472
|
-
loadOrGenerateRootCA() {
|
|
473
|
-
const caDir = join(tmpdir(), "preflight-ca");
|
|
474
|
-
const keyPath = join(caDir, "ca.key");
|
|
475
|
-
const certPath = join(caDir, "ca.pem");
|
|
476
|
-
try {
|
|
477
|
-
const existingKey = readFileSync(keyPath, "utf8");
|
|
478
|
-
const existingCert = readFileSync(certPath, "utf8");
|
|
479
|
-
if (existingKey && existingCert) {
|
|
480
|
-
// Validate the cert works by creating a test context
|
|
481
|
-
try {
|
|
482
|
-
tls.createSecureContext({ key: existingKey, cert: existingCert });
|
|
483
|
-
return { key: existingKey, cert: existingCert };
|
|
484
|
-
}
|
|
485
|
-
catch { /* regenerate */ }
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
catch { /* generate new */ }
|
|
489
|
-
const ca = this.generateRootCA();
|
|
490
|
-
try {
|
|
491
|
-
execSync(`mkdir -p "${caDir}"`, { stdio: "pipe" });
|
|
492
|
-
writeFileSync(keyPath, ca.key, { mode: 0o600 });
|
|
493
|
-
writeFileSync(certPath, ca.cert);
|
|
494
|
-
}
|
|
495
|
-
catch { /* non-fatal */ }
|
|
496
|
-
return ca;
|
|
497
|
-
}
|
|
498
|
-
generateRootCA() {
|
|
499
|
-
const id = randomUUID().slice(0, 8);
|
|
500
|
-
const dir = tmpdir();
|
|
501
|
-
const keyPath = join(dir, `preflight-ca-${id}.key`);
|
|
502
|
-
const certPath = join(dir, `preflight-ca-${id}.pem`);
|
|
503
|
-
try {
|
|
504
|
-
execSync(`openssl req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 ` +
|
|
505
|
-
`-subj "/CN=Preflight Mock CA/O=Preflight/OU=MITM Proxy" ` +
|
|
506
|
-
`-addext "basicConstraints=critical,CA:TRUE" ` +
|
|
507
|
-
`-addext "keyUsage=critical,keyCertSign,cRLSign"`, { stdio: "pipe", timeout: 10_000 });
|
|
508
|
-
const key = readFileSync(keyPath, "utf8");
|
|
509
|
-
const cert = readFileSync(certPath, "utf8");
|
|
510
|
-
return { key, cert };
|
|
511
|
-
}
|
|
512
|
-
finally {
|
|
513
|
-
try {
|
|
514
|
-
unlinkSync(keyPath);
|
|
515
|
-
}
|
|
516
|
-
catch { }
|
|
517
|
-
try {
|
|
518
|
-
unlinkSync(certPath);
|
|
519
|
-
}
|
|
520
|
-
catch { }
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
getServerSecureContext(hostname) {
|
|
524
|
-
if (!this.rootCA)
|
|
525
|
-
return null;
|
|
526
|
-
const cached = this.certCache.get(hostname);
|
|
527
|
-
if (cached)
|
|
528
|
-
return cached;
|
|
529
|
-
const { key, cert } = this.generateServerCert(hostname);
|
|
530
|
-
const ctx = tls.createSecureContext({ key, cert });
|
|
531
|
-
this.certCache.set(hostname, ctx);
|
|
532
|
-
return ctx;
|
|
533
|
-
}
|
|
534
|
-
generateServerCert(hostname) {
|
|
535
|
-
const id = randomUUID().slice(0, 8);
|
|
536
|
-
const dir = tmpdir();
|
|
537
|
-
const keyPath = join(dir, `preflight-srv-${id}.key`);
|
|
538
|
-
const csrPath = join(dir, `preflight-srv-${id}.csr`);
|
|
539
|
-
const certPath = join(dir, `preflight-srv-${id}.pem`);
|
|
540
|
-
const caKeyPath = join(dir, `preflight-ca-${id}.key`);
|
|
541
|
-
const caCertPath = join(dir, `preflight-ca-${id}.pem`);
|
|
542
|
-
try {
|
|
543
|
-
writeFileSync(caKeyPath, this.rootCA.key);
|
|
544
|
-
writeFileSync(caCertPath, this.rootCA.cert);
|
|
545
|
-
execSync(`openssl req -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${csrPath}" -subj "/CN=${hostname}"`, { stdio: "pipe", timeout: 10_000 });
|
|
546
|
-
execSync(`openssl x509 -req -in "${csrPath}" -CA "${caCertPath}" -CAkey "${caKeyPath}" -CAcreateserial ` +
|
|
547
|
-
`-out "${certPath}" -days 365 -extfile <(printf "subjectAltName=DNS:${hostname}\\nbasicConstraints=CA:FALSE\\nkeyUsage=digitalSignature,keyEncipherment\\nextendedKeyUsage=serverAuth")`, { stdio: "pipe", timeout: 10_000, shell: "/bin/bash" });
|
|
548
|
-
const key = readFileSync(keyPath, "utf8");
|
|
549
|
-
const cert = readFileSync(certPath, "utf8");
|
|
550
|
-
return { key, cert };
|
|
551
|
-
}
|
|
552
|
-
catch {
|
|
553
|
-
// Fall back to tunnel mode if cert generation fails
|
|
554
|
-
throw new Error(`Failed to generate cert for ${hostname}`);
|
|
555
|
-
}
|
|
556
|
-
finally {
|
|
557
|
-
try {
|
|
558
|
-
unlinkSync(keyPath);
|
|
559
|
-
}
|
|
560
|
-
catch { }
|
|
561
|
-
try {
|
|
562
|
-
unlinkSync(csrPath);
|
|
563
|
-
}
|
|
564
|
-
catch { }
|
|
565
|
-
try {
|
|
566
|
-
unlinkSync(certPath);
|
|
567
|
-
}
|
|
568
|
-
catch { }
|
|
569
|
-
try {
|
|
570
|
-
unlinkSync(caKeyPath);
|
|
571
|
-
}
|
|
572
|
-
catch { }
|
|
573
|
-
try {
|
|
574
|
-
unlinkSync(caCertPath);
|
|
575
|
-
}
|
|
576
|
-
catch { }
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|