forked from yfe404/proxy-mcp
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmobile.ts
More file actions
444 lines (408 loc) · 17.6 KB
/
Copy pathmobile.ts
File metadata and controls
444 lines (408 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
/**
* One-command mobile-capture orchestrator.
*
* proxy_mobile_setup: detect USB iface → start explicit + transparent listeners
* → emit a sudo-runnable script with iptables + sysctl + nmcli → inject CA on
* an Android device via the existing AndroidAdbInterceptor.
*
* proxy_mobile_teardown: reverse operation — emit sudo script to drop rules,
* deactivate Android target, stop both listeners.
*
* MCP runs as the invoking user; iptables requires root, so the tool emits a
* script and returns its path rather than executing it directly. Caller runs
* `sudo bash <path>` once. Keeps everything auditable and distro-portable.
*
* Pairs with the `proxy-ap-card` firmware (XIAO ESP32-S3 rogue AP presenting
* as USB NCM): the card handles WiFi + NAPT, this tool wires the laptop side.
*/
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { readdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { proxyManager } from "../state.js";
import { interceptorManager } from "../interceptors/manager.js";
import { mergeUpstreamPassword, upstreamPasswordSource } from "../utils.js";
function errorToString(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
/** Return the driver name for a given interface via /sys, or null. */
function ifaceDriver(iface: string): string | null {
try {
const link = readFileSync(`/sys/class/net/${iface}/device/uevent`, "utf8");
const m = link.match(/DRIVER=(\S+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
/** Auto-detect USB-ethernet-to-proxy-ap-card interface. */
function detectApIface(): { iface: string; reason: string } | null {
let netIfaces: string[];
try {
netIfaces = readdirSync("/sys/class/net");
} catch {
return null;
}
// Priority 1: cdc_ncm driver (what the proxy-ap-card firmware presents as)
for (const name of netIfaces) {
if (ifaceDriver(name) === "cdc_ncm") {
return { iface: name, reason: "driver=cdc_ncm (proxy-ap-card default)" };
}
}
// Priority 2: cdc_ether (alternative CDC Ethernet class)
for (const name of netIfaces) {
if (ifaceDriver(name) === "cdc_ether") {
return { iface: name, reason: "driver=cdc_ether" };
}
}
return null;
}
/** Detect host's default internet-facing interface. */
function detectEgressIface(): string {
try {
const routes = readFileSync("/proc/net/route", "utf8").split("\n").slice(1);
for (const line of routes) {
const parts = line.split("\t");
if (parts.length < 2) continue;
if (parts[1] === "00000000") return parts[0]; // default route
}
} catch { /* fall through */ }
return "eth0";
}
function buildSetupScript(opts: {
apIface: string;
apSubnet: string;
apAddress: string;
egressIface: string;
explicitPort: number;
transparentPort: number;
blockQuic: boolean;
}): string {
return `#!/bin/bash
# proxy-mcp mobile capture — apply host-side network rules.
# Safe to re-run: rules are idempotent (-C checks before -A).
set -euo pipefail
AP_IFACE="${opts.apIface}"
AP_ADDR="${opts.apAddress}"
AP_SUBNET="${opts.apSubnet}"
EGRESS_IFACE="${opts.egressIface}"
HTTP_PORT="${opts.explicitPort}"
HTTPS_PORT="${opts.transparentPort}"
echo "AP iface : \$AP_IFACE"
echo "AP address : \$AP_ADDR"
echo "Egress iface : \$EGRESS_IFACE"
echo "proxy-mcp HTTP : :\$HTTP_PORT"
echo "proxy-mcp HTTPS: :\$HTTPS_PORT (transparent)"
echo
# Let proxy-mcp manage the iface — unhook NetworkManager if it's attached.
nmcli device set "\$AP_IFACE" managed no 2>/dev/null || true
# Assign a static address if one isn't set. Skipped if already configured.
if ! ip -o -4 addr show dev "\$AP_IFACE" | grep -qw "\$AP_ADDR"; then
ip link set "\$AP_IFACE" up
ip addr flush dev "\$AP_IFACE"
ip addr add "\$AP_ADDR" dev "\$AP_IFACE"
fi
# IP forwarding on.
sysctl -w net.ipv4.ip_forward=1 >/dev/null
# Fresh dedicated chain so rules are easy to flush.
iptables -t nat -F PROXY_MCP_PREROUTING 2>/dev/null || true
iptables -t nat -X PROXY_MCP_PREROUTING 2>/dev/null || true
iptables -t nat -N PROXY_MCP_PREROUTING
# HTTP → proxy-mcp explicit listener (handles absolute-URL HTTP requests).
iptables -t nat -A PROXY_MCP_PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports "\$HTTP_PORT"
# HTTPS → proxy-mcp transparent listener (SNI-based MITM, no CONNECT needed).
iptables -t nat -A PROXY_MCP_PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports "\$HTTPS_PORT"
# Hook on the AP iface only.
iptables -t nat -C PREROUTING -i "\$AP_IFACE" -j PROXY_MCP_PREROUTING 2>/dev/null \\
|| iptables -t nat -A PREROUTING -i "\$AP_IFACE" -j PROXY_MCP_PREROUTING
${opts.blockQuic
? `# Block QUIC on the AP iface so apps fall back to TCP/TLS (capturable).
iptables -C FORWARD -i "\$AP_IFACE" -p udp --dport 443 -j DROP 2>/dev/null \\
|| iptables -A FORWARD -i "\$AP_IFACE" -p udp --dport 443 -j DROP
`
: "# QUIC forwarding left intact (will not be captured by the transparent listener).\n"
}
# Masquerade out through the real egress.
iptables -t nat -C POSTROUTING -s "\$AP_SUBNET" -o "\$EGRESS_IFACE" -j MASQUERADE 2>/dev/null \\
|| iptables -t nat -A POSTROUTING -s "\$AP_SUBNET" -o "\$EGRESS_IFACE" -j MASQUERADE
echo
echo "Done. Connect the target device to the proxy-ap WiFi — traffic will flow"
echo "through proxy-mcp on ports \$HTTP_PORT (HTTP) and \$HTTPS_PORT (HTTPS)."
`;
}
function buildTeardownScript(opts: {
apIface: string;
apSubnet: string;
egressIface: string;
blockQuic: boolean;
}): string {
return `#!/bin/bash
# proxy-mcp mobile capture — remove host-side network rules.
set -u
AP_IFACE="${opts.apIface}"
AP_SUBNET="${opts.apSubnet}"
EGRESS_IFACE="${opts.egressIface}"
iptables -t nat -D PREROUTING -i "\$AP_IFACE" -j PROXY_MCP_PREROUTING 2>/dev/null || true
iptables -t nat -F PROXY_MCP_PREROUTING 2>/dev/null || true
iptables -t nat -X PROXY_MCP_PREROUTING 2>/dev/null || true
${opts.blockQuic
? `iptables -D FORWARD -i "\$AP_IFACE" -p udp --dport 443 -j DROP 2>/dev/null || true\n`
: ""}iptables -t nat -D POSTROUTING -s "\$AP_SUBNET" -o "\$EGRESS_IFACE" -j MASQUERADE 2>/dev/null || true
sysctl -w net.ipv4.ip_forward=0 >/dev/null
ip addr flush dev "\$AP_IFACE" 2>/dev/null || true
nmcli device set "\$AP_IFACE" managed yes 2>/dev/null || true
echo "Teardown complete."
`;
}
function writeScript(content: string, suffix: string): string {
const name = `proxy-mcp-${suffix}-${randomBytes(4).toString("hex")}.sh`;
const path = join(tmpdir(), name);
writeFileSync(path, content, "utf8");
chmodSync(path, 0o755);
return path;
}
export function registerMobileTools(server: McpServer): void {
server.tool(
"proxy_mobile_setup",
"One-command mobile capture: start explicit + transparent listeners, optionally inject the CA on an Android device, and emit a sudo-runnable script that wires iptables/sysctl/nmcli on the AP iface. Designed to pair with the proxy-ap-card firmware (ESP32-S3 rogue AP over USB-NCM).",
{
ap_iface: z.string().optional().describe("AP/USB interface name. Auto-detected via cdc_ncm driver if omitted."),
ap_address: z.string().optional().default("192.168.99.2/24").describe("Laptop-side address on the AP iface (default: 192.168.99.2/24, matches proxy-ap-card firmware)."),
ap_subnet: z.string().optional().default("192.168.4.0/24").describe("Subnet the AP serves to clients (default: 192.168.4.0/24, matches proxy-ap-card firmware)."),
egress_iface: z.string().optional().describe("Host's internet-facing iface. Auto-detected from /proc/net/route if omitted."),
explicit_port: z.number().optional().default(8080).describe("Port for the explicit HTTP proxy (default: 8080)."),
transparent_port: z.number().optional().default(8443).describe("Port for the transparent HTTPS listener (default: 8443)."),
block_quic: z.boolean().optional().default(true).describe("Drop UDP/443 on the AP iface so apps fall back to TCP/TLS (capturable). Default: true."),
upstream_proxy_url: z.string().optional().describe("Optional upstream proxy URL (socks5://user:pass@host:port or http://...). Sets the global upstream for BOTH listeners. If it has a username but no password, the password is filled in from PROXY_MCP_UPSTREAM_PASSWORD, but only when PROXY_MCP_UPSTREAM_HOST is also set and matches this URL's hostname. The response reports password_source: env | url | none."),
android_serial: z.string().optional().describe("ADB serial of an Android device to inject the CA on. If omitted, no cert injection is attempted."),
inject_cert: z.boolean().optional().default(true).describe("Inject the CA into the Android device's system store. Ignored if android_serial is omitted."),
},
async ({
ap_iface,
ap_address,
ap_subnet,
egress_iface,
explicit_port,
transparent_port,
block_quic,
upstream_proxy_url,
android_serial,
inject_cert,
}) => {
try {
// Validate and resolve the upstream before starting anything, so a
// typo'd URL cannot leave both listeners running behind an error.
if (upstream_proxy_url && !URL.canParse(upstream_proxy_url)) {
throw new Error("upstream_proxy_url is not a parseable URL — check the scheme, e.g. socks5://host:1080");
}
let resolvedUpstream: string | undefined;
let passwordSource: ReturnType<typeof upstreamPasswordSource> = null;
if (upstream_proxy_url) {
resolvedUpstream = mergeUpstreamPassword(upstream_proxy_url);
passwordSource = upstreamPasswordSource(upstream_proxy_url, resolvedUpstream);
}
// 1. Resolve AP iface.
let apIface = ap_iface;
let ifaceReason = "user-provided";
if (!apIface) {
const detected = detectApIface();
if (!detected) {
return { content: [{ type: "text", text: JSON.stringify({
status: "error",
error: "Could not auto-detect an AP interface. Plug in the proxy-ap-card (or another cdc_ncm USB NCM device) and retry, or pass ap_iface explicitly. Known interfaces:",
interfaces: readdirSync("/sys/class/net").filter((n) => n !== "lo"),
}) }] };
}
apIface = detected.iface;
ifaceReason = detected.reason;
}
// 2. Resolve egress.
const egressIface = egress_iface ?? detectEgressIface();
// 3. Explicit proxy.
let explicitPortUsed = explicit_port;
if (!proxyManager.isRunning()) {
const started = await proxyManager.start(explicit_port);
explicitPortUsed = started.port;
} else {
explicitPortUsed = proxyManager.getPort() ?? explicit_port;
}
// 4. Transparent listener.
let transparentPortUsed = transparent_port;
if (!proxyManager.isTransparentRunning()) {
const startedTp = await proxyManager.startTransparent(transparent_port);
transparentPortUsed = startedTp.port;
} else {
transparentPortUsed = proxyManager.getTransparentPort() ?? transparent_port;
}
// 5. Upstream (optional).
let upstreamSet = false;
if (resolvedUpstream) {
await proxyManager.setGlobalUpstream({ proxyUrl: resolvedUpstream });
upstreamSet = true;
}
// 6. Android CA injection (optional).
let certInjected = false;
let androidTargetId: string | null = null;
if (android_serial && inject_cert) {
const cert = proxyManager.getCert();
if (!cert) throw new Error("Proxy started but no cert was generated — this is a bug.");
const result = await interceptorManager.activate("android-adb", {
proxyPort: explicitPortUsed,
certPem: cert.cert,
certFingerprint: cert.fingerprint,
serial: android_serial,
injectCert: true,
setupTunnel: false, // AP + iptables handle routing; no reverse tunnel needed
setWifiProxy: false, // same reason
});
certInjected = true;
androidTargetId = result.targetId;
}
// 7. Emit sudo script.
const scriptPath = writeScript(
buildSetupScript({
apIface,
apAddress: ap_address,
apSubnet: ap_subnet,
egressIface,
explicitPort: explicitPortUsed,
transparentPort: transparentPortUsed,
blockQuic: block_quic,
}),
"mobile-setup",
);
return {
content: [{
type: "text",
text: JSON.stringify({
status: "success",
ap_iface: apIface,
ap_iface_reason: ifaceReason,
ap_address,
ap_subnet,
egress_iface: egressIface,
explicit_port: explicitPortUsed,
transparent_port: transparentPortUsed,
block_quic,
upstream_set: upstreamSet,
...(passwordSource ? { password_source: passwordSource } : {}),
cert_injected: certInjected,
android_target_id: androidTargetId,
sudo_script: scriptPath,
sudo_command: `sudo bash ${scriptPath}`,
next_steps: [
`Run: sudo bash ${scriptPath}`,
"Connect the target device to the proxy-ap WiFi network (credentials live in the proxy-ap-card firmware).",
"Call proxy_list_traffic — transparent HTTPS exchanges appear with source=\"transparent\", HTTP with source=\"explicit\".",
"When done, call proxy_mobile_teardown and run the emitted sudo script.",
],
}),
}],
};
} catch (e) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] };
}
},
);
server.tool(
"proxy_mobile_teardown",
"Reverse proxy_mobile_setup: deactivate the Android target if any, stop the transparent + explicit listeners, and emit a sudo-runnable script that removes the iptables rules and restores NetworkManager management.",
{
ap_iface: z.string().optional().describe("AP/USB interface name. Auto-detected via cdc_ncm if omitted."),
ap_subnet: z.string().optional().default("192.168.4.0/24").describe("Subnet the AP serves (must match setup)."),
egress_iface: z.string().optional().describe("Host's egress iface (must match setup)."),
block_quic: z.boolean().optional().default(true).describe("Whether the QUIC DROP rule was set up (so we know to remove it)."),
android_target_id: z.string().optional().describe("Android target ID to deactivate (from proxy_mobile_setup response)."),
stop_proxy: z.boolean().optional().default(false).describe("Also stop the explicit proxy (default: keep it running for continued use)."),
},
async ({ ap_iface, ap_subnet, egress_iface, block_quic, android_target_id, stop_proxy }) => {
try {
let apIface = ap_iface;
if (!apIface) {
const detected = detectApIface();
if (!detected) {
return { content: [{ type: "text", text: JSON.stringify({
status: "error",
error: "Could not auto-detect AP iface for teardown. Pass ap_iface explicitly.",
}) }] };
}
apIface = detected.iface;
}
const egressIface = egress_iface ?? detectEgressIface();
if (android_target_id) {
await interceptorManager.deactivate("android-adb", android_target_id).catch(() => {});
}
if (proxyManager.isTransparentRunning()) {
await proxyManager.stopTransparent().catch(() => {});
}
if (stop_proxy && proxyManager.isRunning()) {
await proxyManager.stop().catch(() => {});
}
const scriptPath = writeScript(
buildTeardownScript({
apIface,
apSubnet: ap_subnet,
egressIface,
blockQuic: block_quic,
}),
"mobile-teardown",
);
return {
content: [{
type: "text",
text: JSON.stringify({
status: "success",
ap_iface: apIface,
egress_iface: egressIface,
android_target_deactivated: !!android_target_id,
transparent_stopped: true,
explicit_stopped: stop_proxy,
sudo_script: scriptPath,
sudo_command: `sudo bash ${scriptPath}`,
}),
}],
};
} catch (e) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] };
}
},
);
server.tool(
"proxy_mobile_detect_iface",
"Auto-detect the USB-NCM interface the proxy-ap-card presents as (via the cdc_ncm driver). Returns null + iface list if none found.",
{},
async () => {
try {
const detected = detectApIface();
if (!detected) {
return {
content: [{
type: "text",
text: JSON.stringify({
status: "success",
found: false,
interfaces: readdirSync("/sys/class/net").filter((n) => n !== "lo"),
}),
}],
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
status: "success",
found: true,
iface: detected.iface,
reason: detected.reason,
}),
}],
};
} catch (e) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] };
}
},
);
}