Skip to content

Commit 45c30a3

Browse files
committed
fix: resolve reverse tunnel and wifi proxy config leak on session end or crash
1 parent ef4f447 commit 45c30a3

5 files changed

Lines changed: 131 additions & 7 deletions

File tree

src/plugin.ts

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
isRealDevice,
1919
getAdbReverseTunnels,
2020
getCurrentWifiProxyConfig,
21+
removeReverseTunnel,
2122
ADBInstance,
2223
UDID,
2324
} from './utils/adb';
@@ -89,6 +90,62 @@ export class AppiumInterceptorPlugin extends BasePlugin {
8990
super(name, cliArgs);
9091
log.debug(`📱 Initializing plugin with CLI args: ${JSON.stringify(cliArgs)}`);
9192
this.pluginArgs = Object.assign({}, DefaultPluginArgs, cliArgs as unknown as IPluginArgs);
93+
this.registerProcessExitHandlers();
94+
}
95+
96+
private registerProcessExitHandlers() {
97+
let isCleaningUp = false;
98+
99+
const cleanupAllProxies = async (signal: string) => {
100+
if (isCleaningUp) return;
101+
isCleaningUp = true;
102+
103+
const sessionIds = proxyCache.getAllSessionIds();
104+
if (sessionIds.length > 0) {
105+
log.info(
106+
`[Cleanup] Process received ${signal}. Cleaning up ${sessionIds.length} active proxy sessions...`,
107+
);
108+
for (const sessionId of sessionIds) {
109+
try {
110+
await this.clearProxy(undefined, sessionId);
111+
} catch (err: any) {
112+
log.error(
113+
`[Cleanup] Error during process exit cleanup for session ${sessionId}: ${err.message}`,
114+
);
115+
}
116+
}
117+
}
118+
119+
if (signal === 'SIGINT' || signal === 'SIGTERM') {
120+
// Send the signal to ourselves again so default or other handlers can run
121+
process.kill(process.pid, signal);
122+
}
123+
};
124+
125+
const cleanupWithTimeout = async (signal: string, timeoutMs: number = 10000) => {
126+
return Promise.race([
127+
cleanupAllProxies(signal),
128+
new Promise((_, reject) =>
129+
setTimeout(() => reject(new Error('Cleanup timeout')), timeoutMs),
130+
),
131+
]);
132+
};
133+
134+
process.once('SIGINT', async () => {
135+
try {
136+
await cleanupWithTimeout('SIGINT');
137+
} catch (err: any) {
138+
log.error(`Cleanup failed or timed out: ${err.message}`);
139+
}
140+
});
141+
142+
process.once('SIGTERM', async () => {
143+
try {
144+
await cleanupWithTimeout('SIGTERM');
145+
} catch (err: any) {
146+
log.error(`Cleanup failed or timed out: ${err.message}`);
147+
}
148+
});
92149
}
93150

94151
/**
@@ -163,6 +220,14 @@ export class AppiumInterceptorPlugin extends BasePlugin {
163220
const adb = driver.sessions[sessionId]?.adb;
164221
await this.clearProxy(adb, sessionId);
165222
}
223+
224+
const remainingSessions = proxyCache.getAllSessionIds();
225+
for (const sessionId of remainingSessions) {
226+
log.warn(
227+
`[${sessionId}] Session still in proxyCache after unexpected shutdown. Forcing cleanup...`,
228+
);
229+
await this.clearProxy(undefined, sessionId);
230+
}
166231
}
167232

168233
async addMock(_next: any, driver: any, config: MockConfig) {
@@ -258,8 +323,15 @@ export class AppiumInterceptorPlugin extends BasePlugin {
258323
return proxy;
259324
}
260325

261-
private async setupProxy(adb: ADBInstance, sessionId: string, deviceUDID: UDID, interceptionPort?: number) {
262-
log.debug(`setupProxy(sessionId=${sessionId}, deviceUDID:${deviceUDID}, interceptionPort:${interceptionPort})`);
326+
private async setupProxy(
327+
adb: ADBInstance,
328+
sessionId: string,
329+
deviceUDID: UDID,
330+
interceptionPort?: number,
331+
) {
332+
log.debug(
333+
`setupProxy(sessionId=${sessionId}, deviceUDID:${deviceUDID}, interceptionPort:${interceptionPort})`,
334+
);
263335

264336
if (proxyCache.get(sessionId)) {
265337
log.warn(`[${sessionId}] A proxy is already active for this session. Skipping setup.`);
@@ -286,6 +358,7 @@ export class AppiumInterceptorPlugin extends BasePlugin {
286358
: parseJson(this.pluginArgs.blacklisteddomains),
287359
);
288360
const proxy = await setupProxyServer(
361+
adb,
289362
sessionId,
290363
deviceUDID,
291364
realDevice,
@@ -307,18 +380,45 @@ export class AppiumInterceptorPlugin extends BasePlugin {
307380
}
308381
}
309382

310-
private async clearProxy(adb: ADBInstance, sessionId: string) {
383+
private async clearProxy(adb: ADBInstance | undefined, sessionId: string) {
311384
const proxy = proxyCache.get(sessionId);
312385
if (!proxy) {
313386
log.debug(`[${sessionId}] No proxy registered for this session. Nothing to clear.`);
314387
return;
315388
}
316389

390+
const activeAdb = adb || proxy.options.adb;
391+
if (!activeAdb) {
392+
log.warn(
393+
`[${sessionId}] ADB instance is missing. Cannot revert proxy settings or remove reverse tunnels.`,
394+
);
395+
}
396+
317397
log.debug(`[${sessionId}] Reverting device settings and cleaning up proxy resources...`);
318398

319399
try {
320-
// Revert WiFi settings to previous state or off
321-
await configureWifiProxy(adb, proxy.options.deviceUDID, false, proxy.previousGlobalProxy);
400+
const isReal = proxy.options.isRealDevice ?? false;
401+
402+
if (activeAdb) {
403+
// Revert WiFi settings to previous state or off
404+
await configureWifiProxy(
405+
activeAdb,
406+
proxy.options.deviceUDID,
407+
isReal,
408+
proxy.previousGlobalProxy,
409+
);
410+
411+
// Explicitly remove the adb reverse tunnel if this is a real device
412+
if (isReal) {
413+
log.debug(`[${sessionId}] Removing reverse tunnel for port ${proxy.port}...`);
414+
try {
415+
await removeReverseTunnel(activeAdb, proxy.options.deviceUDID, proxy.port);
416+
} catch (tunnelErr: any) {
417+
log.warn(`[${sessionId}] Failed to remove reverse tunnel: ${tunnelErr.message}`);
418+
}
419+
}
420+
}
421+
322422
// Shutdown the local proxy server
323423
await cleanUpProxyServer(proxy);
324424
proxyCache.remove(sessionId);

src/proxy-cache.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ class ProxyCache {
1414
get(sessionId: string) {
1515
return this.cache.get(sessionId);
1616
}
17+
18+
getAllSessionIds(): string[] {
19+
return Array.from(this.cache.keys());
20+
}
21+
22+
clear() {
23+
this.cache.clear();
24+
}
1725
}
1826

1927
export default new ProxyCache();

src/proxy.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Proxy as HttpProxy, IContext, IProxyOptions } from 'http-mitm-proxy';
33
import * as net from 'net';
44
import { ProxyAgent } from 'proxy-agent';
55
import { v4 as uuid } from 'uuid';
6+
import ADB from 'appium-adb';
67
import {
78
addDefaultMocks,
89
compileMockConfig,
@@ -30,6 +31,8 @@ export interface ProxyOptions {
3031
certificatePath: string;
3132
port: number;
3233
ip: string;
34+
adb?: ADB;
35+
isRealDevice?: boolean;
3336
previousConfig?: ProxyOptions;
3437
whitelistedDomains?: string[];
3538
blacklistedDomains?: string[];

src/scripts/test-connection.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ async function addMock(proxy: Proxy) {
8181

8282
async function verifyDeviceConnection(adb: ADBInstance, udid: UDID, certDirectory: string) {
8383
const realDevice = await isRealDevice(adb, udid);
84-
const proxy = await setupProxyServer(uuid(), udid, realDevice, certDirectory);
84+
const proxy = await setupProxyServer(adb, uuid(), udid, realDevice, certDirectory);
8585
addMock(proxy);
8686
await configureWifiProxy(adb, udid, realDevice, proxy.options);
8787
await openUrl(adb, udid, MOCK_BACKEND_URL);

src/utils/proxy.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { minimatch } from 'minimatch';
1818
import http from 'http';
1919
import jsonpath from 'jsonpath';
2020
import regexParser from 'regex-parser';
21+
import ADB from 'appium-adb';
2122
import { validateMockConfig } from '../schema';
2223
import log from '../logger';
2324

@@ -109,6 +110,7 @@ export function modifyResponseBody(ctx: IContext, mockConfig: MockConfig) {
109110
}
110111

111112
export async function setupProxyServer(
113+
adb: ADB,
112114
sessionId: string,
113115
deviceUDID: string,
114116
isRealDevice: boolean,
@@ -127,7 +129,18 @@ export async function setupProxyServer(
127129
const port = interceptionPort ? Number(interceptionPort) : await getPort();
128130
log.info(`Selected port: ${port}`);
129131
const _ip = isRealDevice ? 'localhost' : ip.address('public', 'ipv4');
130-
const proxy = new Proxy({ deviceUDID, sessionId, certificatePath, port, ip: _ip, previousConfig: currentWifiProxyConfig, whitelistedDomains, blacklistedDomains});
132+
const proxy = new Proxy({
133+
deviceUDID: deviceUDID,
134+
sessionId: sessionId,
135+
certificatePath: certificatePath,
136+
port: port,
137+
ip: _ip,
138+
adb: adb,
139+
isRealDevice: isRealDevice,
140+
previousConfig: currentWifiProxyConfig,
141+
whitelistedDomains: whitelistedDomains,
142+
blacklistedDomains: blacklistedDomains,
143+
});
131144
await proxy.start();
132145
if (!proxy.isStarted()) {
133146
throw new Error('Unable to start the proxy server');

0 commit comments

Comments
 (0)