Skip to content

Commit 7426e66

Browse files
author
dankefox
committed
feat: optional reverse TCP tunnel for WDA in NAT-restricted environments
Add opt-in reverse TCP tunnel mode that allows WDA to actively connect outbound to an external relay server, enabling remote control in environments where inbound connections to the iOS device are not feasible (symmetric NAT, multi-layer firewalls, corporate VPNs, etc.). Controlled via environment variables (disabled by default): - WDA_RELAY_HOST: relay server address - WDA_RELAY_PORT: relay server port (default 8201) When not configured, WDA behavior is completely unchanged. Includes: - FBConfiguration: relay host/port accessors from env vars - FBWebServer: reverse tunnel client with auto-reconnect - Scripts/wda-relay-server.js: example Node.js relay server
1 parent 13d61a8 commit 7426e66

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

Scripts/wda-relay-server.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env node
2+
/**
3+
* WDA Reverse Tunnel Relay Server
4+
*
5+
* This server acts as a bridge between WDA (running on an iOS device behind NAT)
6+
* and HTTP clients. WDA connects outbound to this relay; HTTP clients connect
7+
* to localhost:8100 as usual.
8+
*
9+
* Usage:
10+
* WDA_RELAY_HOST=<this-server-ip> WDA_RELAY_PORT=8201 xcodebuild test-without-building ...
11+
* node wda-relay-server.js # relay on 8201, proxy on 8100
12+
* node wda-relay-server.js 9201 9100 # custom ports
13+
*
14+
* Protocol (between relay and WDA):
15+
* [4-byte big-endian length][payload]
16+
* Request payload: raw HTTP request (method + headers + body)
17+
* Response payload: raw HTTP response (status + headers + body)
18+
*/
19+
20+
const net = require('net');
21+
const http = require('http');
22+
23+
const RELAY_PORT = parseInt(process.argv[2]) || 8201;
24+
const PROXY_PORT = parseInt(process.argv[3]) || 8100;
25+
26+
let wdaSocket = null;
27+
let pendingRequests = new Map();
28+
let requestCounter = 0;
29+
30+
// --- Relay server: accepts reverse connection from WDA ---
31+
const relayServer = net.createServer((socket) => {
32+
console.log(`[relay] WDA connected from ${socket.remoteAddress}`);
33+
wdaSocket = socket;
34+
35+
let buffer = Buffer.alloc(0);
36+
37+
socket.on('data', (chunk) => {
38+
buffer = Buffer.concat([buffer, chunk]);
39+
40+
while (buffer.length >= 4) {
41+
const len = buffer.readUInt32BE(0);
42+
if (buffer.length < 4 + len) break;
43+
44+
const payload = buffer.slice(4, 4 + len);
45+
buffer = buffer.slice(4 + len);
46+
47+
// Route response to the oldest pending HTTP request
48+
const oldest = pendingRequests.entries().next().value;
49+
if (oldest) {
50+
const [id, res] = oldest;
51+
pendingRequests.delete(id);
52+
53+
const text = payload.toString();
54+
const headerEnd = text.indexOf('\r\n\r\n');
55+
if (headerEnd !== -1) {
56+
const statusMatch = text.match(/^HTTP\/\d\.\d (\d+)/);
57+
const statusCode = statusMatch ? parseInt(statusMatch[1]) : 200;
58+
const body = payload.slice(headerEnd + 4);
59+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
60+
res.end(body);
61+
} else {
62+
res.writeHead(200, { 'Content-Type': 'application/json' });
63+
res.end(payload);
64+
}
65+
}
66+
}
67+
});
68+
69+
socket.on('close', () => {
70+
console.log('[relay] WDA disconnected');
71+
wdaSocket = null;
72+
});
73+
74+
socket.on('error', (err) => {
75+
console.error('[relay] Socket error:', err.message);
76+
wdaSocket = null;
77+
});
78+
});
79+
80+
// --- HTTP proxy: accepts normal WDA API requests ---
81+
const proxyServer = http.createServer((req, res) => {
82+
if (!wdaSocket || wdaSocket.destroyed) {
83+
res.writeHead(503, { 'Content-Type': 'application/json' });
84+
res.end(JSON.stringify({ error: 'WDA not connected to relay' }));
85+
return;
86+
}
87+
88+
let body = [];
89+
req.on('data', (chunk) => body.push(chunk));
90+
req.on('end', () => {
91+
const bodyBuf = Buffer.concat(body);
92+
const httpReq = `${req.method} ${req.url} HTTP/1.1\r\nHost: localhost\r\n` +
93+
Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n') +
94+
'\r\n\r\n' + bodyBuf.toString();
95+
96+
const reqBuf = Buffer.from(httpReq);
97+
const lenBuf = Buffer.alloc(4);
98+
lenBuf.writeUInt32BE(reqBuf.length);
99+
100+
const id = requestCounter++;
101+
pendingRequests.set(id, res);
102+
103+
try {
104+
wdaSocket.write(Buffer.concat([lenBuf, reqBuf]));
105+
} catch (err) {
106+
pendingRequests.delete(id);
107+
res.writeHead(502, { 'Content-Type': 'application/json' });
108+
res.end(JSON.stringify({ error: 'Failed to forward request' }));
109+
}
110+
});
111+
});
112+
113+
relayServer.listen(RELAY_PORT, () => {
114+
console.log(`[relay] Waiting for WDA on port ${RELAY_PORT}`);
115+
});
116+
117+
proxyServer.listen(PROXY_PORT, () => {
118+
console.log(`[proxy] HTTP proxy on port ${PROXY_PORT}`);
119+
console.log(`\nUsage: set WDA_RELAY_HOST and WDA_RELAY_PORT env vars when launching WDA`);
120+
console.log(` WDA_RELAY_HOST=<this-ip> WDA_RELAY_PORT=${RELAY_PORT} xcodebuild test-without-building ...`);
121+
console.log(` curl http://localhost:${PROXY_PORT}/status`);
122+
});

WebDriverAgentLib/Routing/FBWebServer.m

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#import "FBUnknownCommands.h"
2323
#import "FBConfiguration.h"
2424
#import "FBLogger.h"
25+
#import <Network/Network.h>
2526

2627
#import "XCUIDevice+FBHelpers.h"
2728

@@ -78,6 +79,7 @@ - (void)startServing
7879
self.exceptionHandler = [FBExceptionHandler new];
7980
[self startHTTPServer];
8081
[self initScreenshotsBroadcaster];
82+
[self startReverseTunnel];
8183

8284
self.keepAlive = YES;
8385
NSRunLoop *runLoop = [NSRunLoop mainRunLoop];
@@ -274,4 +276,188 @@ - (void)registerServerKeyRouteHandlers
274276
[self registerRouteHandlers:@[FBUnknownCommands.class]];
275277
}
276278

279+
280+
#pragma mark - Reverse TCP Tunnel
281+
282+
- (void)startReverseTunnel
283+
{
284+
NSString *relayHost = FBConfiguration.relayHost;
285+
if (!relayHost) {
286+
return; // Reverse tunnel not configured — default behavior unchanged
287+
}
288+
289+
NSInteger relayPort = FBConfiguration.relayPort;
290+
[FBLogger logFmt:@"[ReverseTunnel] Connecting to relay %@:%ld", relayHost, (long)relayPort];
291+
292+
nw_endpoint_t endpoint = nw_endpoint_create_host(
293+
[relayHost UTF8String],
294+
[[NSString stringWithFormat:@"%ld", (long)relayPort] UTF8String]
295+
);
296+
nw_parameters_t params = nw_parameters_create_secure_tcp(
297+
NW_PARAMETERS_DISABLE_PROTOCOL,
298+
NW_PARAMETERS_DEFAULT_CONFIGURATION
299+
);
300+
nw_connection_t conn = nw_connection_create(endpoint, params);
301+
nw_connection_set_queue(conn, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
302+
303+
__weak typeof(self) weakSelf = self;
304+
305+
nw_connection_set_state_changed_handler(conn, ^(nw_connection_state_t state, nw_error_t error) {
306+
switch (state) {
307+
case nw_connection_state_ready:
308+
[FBLogger logFmt:@"[ReverseTunnel] Connected to relay"];
309+
[weakSelf rt_readRequestFromConnection:conn];
310+
break;
311+
case nw_connection_state_failed:
312+
[FBLogger logFmt:@"[ReverseTunnel] Connection failed: %@, retrying in 5s", error];
313+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
314+
dispatch_get_global_queue(0, 0), ^{
315+
[weakSelf startReverseTunnel];
316+
});
317+
break;
318+
case nw_connection_state_waiting:
319+
[FBLogger logFmt:@"[ReverseTunnel] Waiting for network path"];
320+
break;
321+
default:
322+
break;
323+
}
324+
});
325+
326+
nw_connection_start(conn);
327+
}
328+
329+
- (void)rt_readRequestFromConnection:(nw_connection_t)conn
330+
{
331+
// Protocol: 4-byte big-endian length prefix, then HTTP request payload
332+
nw_connection_receive(conn, 4, 4, ^(dispatch_data_t lenData, nw_content_context_t ctx,
333+
bool isComplete, nw_error_t error) {
334+
if (error || !lenData) {
335+
[FBLogger logFmt:@"[ReverseTunnel] Read error, reconnecting"];
336+
nw_connection_cancel(conn);
337+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
338+
dispatch_get_global_queue(0, 0), ^{
339+
[self startReverseTunnel];
340+
});
341+
return;
342+
}
343+
344+
__block uint32_t bodyLen = 0;
345+
dispatch_data_apply(lenData, ^bool(dispatch_data_t region, size_t offset,
346+
const void *buffer, size_t size) {
347+
if (size >= 4) {
348+
memcpy(&bodyLen, buffer, 4);
349+
bodyLen = ntohl(bodyLen);
350+
}
351+
return true;
352+
});
353+
354+
if (bodyLen == 0 || bodyLen > 10 * 1024 * 1024) {
355+
[self rt_readRequestFromConnection:conn];
356+
return;
357+
}
358+
359+
nw_connection_receive(conn, bodyLen, bodyLen, ^(dispatch_data_t bodyData,
360+
nw_content_context_t ctx2,
361+
bool isComplete2, nw_error_t error2) {
362+
if (error2 || !bodyData) {
363+
nw_connection_cancel(conn);
364+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
365+
dispatch_get_global_queue(0, 0), ^{
366+
[self startReverseTunnel];
367+
});
368+
return;
369+
}
370+
371+
NSMutableData *reqData = [NSMutableData data];
372+
dispatch_data_apply(bodyData, ^bool(dispatch_data_t region, size_t offset,
373+
const void *buffer, size_t size) {
374+
[reqData appendBytes:buffer length:size];
375+
return true;
376+
});
377+
378+
[self rt_forwardRequest:reqData toConnection:conn];
379+
});
380+
});
381+
}
382+
383+
- (void)rt_forwardRequest:(NSData *)requestData toConnection:(nw_connection_t)conn
384+
{
385+
NSString *reqStr = [[NSString alloc] initWithData:requestData encoding:NSUTF8StringEncoding];
386+
if (!reqStr) {
387+
[self rt_readRequestFromConnection:conn];
388+
return;
389+
}
390+
391+
NSArray *lines = [reqStr componentsSeparatedByString:@"\r\n"];
392+
if (lines.count == 0) {
393+
[self rt_readRequestFromConnection:conn];
394+
return;
395+
}
396+
397+
NSArray *firstLineParts = [lines[0] componentsSeparatedByString:@" "];
398+
if (firstLineParts.count < 2) {
399+
[self rt_readRequestFromConnection:conn];
400+
return;
401+
}
402+
403+
NSString *method = firstLineParts[0];
404+
NSString *path = firstLineParts[1];
405+
406+
NSRange portRange = FBConfiguration.bindingPortRange;
407+
NSString *urlStr = [NSString stringWithFormat:@"http://127.0.0.1:%lu%@",
408+
(unsigned long)portRange.location, path];
409+
NSURL *url = [NSURL URLWithString:urlStr];
410+
if (!url) {
411+
[self rt_readRequestFromConnection:conn];
412+
return;
413+
}
414+
415+
NSMutableURLRequest *localReq = [NSMutableURLRequest requestWithURL:url];
416+
localReq.HTTPMethod = method;
417+
localReq.timeoutInterval = 60;
418+
419+
NSRange bodyRange = [reqStr rangeOfString:@"\r\n\r\n"];
420+
if (bodyRange.location != NSNotFound) {
421+
NSString *bodyStr = [reqStr substringFromIndex:bodyRange.location + 4];
422+
if (bodyStr.length > 0) {
423+
localReq.HTTPBody = [bodyStr dataUsingEncoding:NSUTF8StringEncoding];
424+
}
425+
}
426+
427+
[[[NSURLSession sharedSession] dataTaskWithRequest:localReq
428+
completionHandler:^(NSData *data, NSURLResponse *response, NSError *err) {
429+
NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)response;
430+
NSInteger statusCode = httpResp ? httpResp.statusCode : 500;
431+
432+
NSString *statusLine = [NSString stringWithFormat:@"HTTP/1.1 %ld OK\r\n", (long)statusCode];
433+
NSMutableString *respStr = [NSMutableString stringWithString:statusLine];
434+
[respStr appendString:@"Content-Type: application/json\r\n"];
435+
[respStr appendFormat:@"Content-Length: %lu\r\n", (unsigned long)(data ? data.length : 0)];
436+
[respStr appendString:@"\r\n"];
437+
438+
NSMutableData *fullResp = [NSMutableData dataWithData:
439+
[respStr dataUsingEncoding:NSUTF8StringEncoding]];
440+
if (data) {
441+
[fullResp appendData:data];
442+
}
443+
444+
uint32_t respLen = htonl((uint32_t)fullResp.length);
445+
NSMutableData *framed = [NSMutableData dataWithBytes:&respLen length:4];
446+
[framed appendData:fullResp];
447+
448+
dispatch_data_t sendData = dispatch_data_create(
449+
framed.bytes, framed.length,
450+
dispatch_get_global_queue(0, 0),
451+
DISPATCH_DATA_DESTRUCTOR_DEFAULT
452+
);
453+
nw_connection_send(conn, sendData, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, true,
454+
^(nw_error_t sendError) {
455+
if (sendError) {
456+
[FBLogger logFmt:@"[ReverseTunnel] Send error: %@", sendError];
457+
}
458+
[self rt_readRequestFromConnection:conn];
459+
});
460+
}] resume];
461+
}
462+
277463
@end

WebDriverAgentLib/Utilities/FBConfiguration.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ extern NSString *const FBSnapshotMaxDepthKey;
141141
+ (CGFloat)mjpegScalingFactor;
142142
+ (void)setMjpegScalingFactor:(CGFloat)scalingFactor;
143143

144+
/**
145+
The host address of an external relay server for reverse TCP tunnel.
146+
When set via WDA_RELAY_HOST environment variable, WDA will actively connect
147+
outbound to this relay instead of only listening for inbound connections.
148+
This enables WDA control in NAT-restricted environments (symmetric NAT,
149+
multi-layer firewalls, VPN tunnels) where inbound connections to the device
150+
are not feasible. Returns nil if not configured (default behavior unchanged).
151+
*/
152+
+ (NSString * _Nullable)relayHost;
153+
154+
/**
155+
The port of the external relay server.
156+
Configured via WDA_RELAY_PORT environment variable. Defaults to 8201.
157+
*/
158+
+ (NSInteger)relayPort;
159+
144160
/**
145161
YES if verbose logging is enabled. NO otherwise.
146162
*/

WebDriverAgentLib/Utilities/FBConfiguration.m

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,24 @@ + (void)setMjpegShouldFixOrientation:(BOOL)enabled {
181181
FBMjpegShouldFixOrientation = enabled;
182182
}
183183

184+
+ (NSString *)relayHost
185+
{
186+
NSString *host = NSProcessInfo.processInfo.environment[@"WDA_RELAY_HOST"];
187+
if (host && [host length] > 0) {
188+
return host;
189+
}
190+
return nil;
191+
}
192+
193+
+ (NSInteger)relayPort
194+
{
195+
NSString *port = NSProcessInfo.processInfo.environment[@"WDA_RELAY_PORT"];
196+
if (port && [port length] > 0) {
197+
return [port integerValue];
198+
}
199+
return 8201;
200+
}
201+
184202
+ (BOOL)verboseLoggingEnabled
185203
{
186204
return [NSProcessInfo.processInfo.environment[@"VERBOSE_LOGGING"] boolValue];

0 commit comments

Comments
 (0)