Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions Scripts/wda-relay-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env node
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't find this script useful.
consider adding an end-to-end test instead

/**
* WDA Reverse Tunnel Relay Server
*
* This server acts as a bridge between WDA (running on an iOS device behind NAT)
* and HTTP clients. WDA connects outbound to this relay; HTTP clients connect
* to localhost:8100 as usual.
*
* Usage:
* WDA_RELAY_HOST=<this-server-ip> WDA_RELAY_PORT=8201 xcodebuild test-without-building ...
* node wda-relay-server.js # relay on 8201, proxy on 8100
* node wda-relay-server.js 9201 9100 # custom ports
*
* Protocol (between relay and WDA):
* [4-byte big-endian length][payload]
* Request payload: raw HTTP request (method + headers + body)
* Response payload: raw HTTP response (status + headers + body)
*/

const net = require('net');
const http = require('http');

const RELAY_PORT = parseInt(process.argv[2]) || 8201;
const PROXY_PORT = parseInt(process.argv[3]) || 8100;

let wdaSocket = null;
let pendingRequests = new Map();
let requestCounter = 0;

// --- Relay server: accepts reverse connection from WDA ---
const relayServer = net.createServer((socket) => {
console.log(`[relay] WDA connected from ${socket.remoteAddress}`);
wdaSocket = socket;

let buffer = Buffer.alloc(0);

socket.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);

while (buffer.length >= 4) {
const len = buffer.readUInt32BE(0);
if (buffer.length < 4 + len) break;

const payload = buffer.slice(4, 4 + len);
buffer = buffer.slice(4 + len);

// Route response to the oldest pending HTTP request
const oldest = pendingRequests.entries().next().value;
if (oldest) {
const [id, res] = oldest;
pendingRequests.delete(id);

const text = payload.toString();
const headerEnd = text.indexOf('\r\n\r\n');
if (headerEnd !== -1) {
const statusMatch = text.match(/^HTTP\/\d\.\d (\d+)/);
const statusCode = statusMatch ? parseInt(statusMatch[1]) : 200;
const body = payload.slice(headerEnd + 4);
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(body);
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(payload);
}
}
}
});

socket.on('close', () => {
console.log('[relay] WDA disconnected');
wdaSocket = null;
});

socket.on('error', (err) => {
console.error('[relay] Socket error:', err.message);
wdaSocket = null;
});
});

// --- HTTP proxy: accepts normal WDA API requests ---
const proxyServer = http.createServer((req, res) => {
if (!wdaSocket || wdaSocket.destroyed) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'WDA not connected to relay' }));
return;
}

let body = [];
req.on('data', (chunk) => body.push(chunk));
req.on('end', () => {
const bodyBuf = Buffer.concat(body);
const httpReq = `${req.method} ${req.url} HTTP/1.1\r\nHost: localhost\r\n` +
Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n') +
'\r\n\r\n' + bodyBuf.toString();

const reqBuf = Buffer.from(httpReq);
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(reqBuf.length);

const id = requestCounter++;
pendingRequests.set(id, res);

try {
wdaSocket.write(Buffer.concat([lenBuf, reqBuf]));
} catch (err) {
pendingRequests.delete(id);
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to forward request' }));
}
});
});

relayServer.listen(RELAY_PORT, () => {
console.log(`[relay] Waiting for WDA on port ${RELAY_PORT}`);
});

proxyServer.listen(PROXY_PORT, () => {
console.log(`[proxy] HTTP proxy on port ${PROXY_PORT}`);
console.log(`\nUsage: set WDA_RELAY_HOST and WDA_RELAY_PORT env vars when launching WDA`);
console.log(` WDA_RELAY_HOST=<this-ip> WDA_RELAY_PORT=${RELAY_PORT} xcodebuild test-without-building ...`);
console.log(` curl http://localhost:${PROXY_PORT}/status`);
});
186 changes: 186 additions & 0 deletions WebDriverAgentLib/Routing/FBWebServer.m
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#import "FBUnknownCommands.h"
#import "FBConfiguration.h"
#import "FBLogger.h"
#import <Network/Network.h>

#import "XCUIDevice+FBHelpers.h"

Expand Down Expand Up @@ -78,6 +79,7 @@ - (void)startServing
self.exceptionHandler = [FBExceptionHandler new];
[self startHTTPServer];
[self initScreenshotsBroadcaster];
[self startReverseTunnel];

self.keepAlive = YES;
NSRunLoop *runLoop = [NSRunLoop mainRunLoop];
Expand Down Expand Up @@ -274,4 +276,188 @@ - (void)registerServerKeyRouteHandlers
[self registerRouteHandlers:@[FBUnknownCommands.class]];
}


#pragma mark - Reverse TCP Tunnel

- (void)startReverseTunnel
Copy link
Copy Markdown

@mykola-mokhnach mykola-mokhnach Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this functionality must be extracted to a separate module and covered by integration tests

{
NSString *relayHost = FBConfiguration.relayHost;
if (!relayHost) {
return; // Reverse tunnel not configured — default behavior unchanged
}

NSInteger relayPort = FBConfiguration.relayPort;
[FBLogger logFmt:@"[ReverseTunnel] Connecting to relay %@:%ld", relayHost, (long)relayPort];

nw_endpoint_t endpoint = nw_endpoint_create_host(
[relayHost UTF8String],
[[NSString stringWithFormat:@"%ld", (long)relayPort] UTF8String]
);
nw_parameters_t params = nw_parameters_create_secure_tcp(
NW_PARAMETERS_DISABLE_PROTOCOL,
NW_PARAMETERS_DEFAULT_CONFIGURATION
);
nw_connection_t conn = nw_connection_create(endpoint, params);
nw_connection_set_queue(conn, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));

__weak typeof(self) weakSelf = self;

nw_connection_set_state_changed_handler(conn, ^(nw_connection_state_t state, nw_error_t error) {
switch (state) {
case nw_connection_state_ready:
[FBLogger logFmt:@"[ReverseTunnel] Connected to relay"];
[weakSelf rt_readRequestFromConnection:conn];
break;
case nw_connection_state_failed:
[FBLogger logFmt:@"[ReverseTunnel] Connection failed: %@, retrying in 5s", error];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
dispatch_get_global_queue(0, 0), ^{
[weakSelf startReverseTunnel];
});
break;
case nw_connection_state_waiting:
[FBLogger logFmt:@"[ReverseTunnel] Waiting for network path"];
break;
default:
break;
}
});

nw_connection_start(conn);
}

- (void)rt_readRequestFromConnection:(nw_connection_t)conn
{
// Protocol: 4-byte big-endian length prefix, then HTTP request payload
nw_connection_receive(conn, 4, 4, ^(dispatch_data_t lenData, nw_content_context_t ctx,
bool isComplete, nw_error_t error) {
if (error || !lenData) {
[FBLogger logFmt:@"[ReverseTunnel] Read error, reconnecting"];
nw_connection_cancel(conn);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
dispatch_get_global_queue(0, 0), ^{
[self startReverseTunnel];
});
return;
}

__block uint32_t bodyLen = 0;
dispatch_data_apply(lenData, ^bool(dispatch_data_t region, size_t offset,
const void *buffer, size_t size) {
if (size >= 4) {
memcpy(&bodyLen, buffer, 4);
bodyLen = ntohl(bodyLen);
}
return true;
});

if (bodyLen == 0 || bodyLen > 10 * 1024 * 1024) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this magic number?

[self rt_readRequestFromConnection:conn];
return;
}

nw_connection_receive(conn, bodyLen, bodyLen, ^(dispatch_data_t bodyData,
nw_content_context_t ctx2,
bool isComplete2, nw_error_t error2) {
if (error2 || !bodyData) {
nw_connection_cancel(conn);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
dispatch_get_global_queue(0, 0), ^{
[self startReverseTunnel];
});
return;
}

NSMutableData *reqData = [NSMutableData data];
dispatch_data_apply(bodyData, ^bool(dispatch_data_t region, size_t offset,
const void *buffer, size_t size) {
[reqData appendBytes:buffer length:size];
return true;
});

[self rt_forwardRequest:reqData toConnection:conn];
});
});
}

- (void)rt_forwardRequest:(NSData *)requestData toConnection:(nw_connection_t)conn
{
NSString *reqStr = [[NSString alloc] initWithData:requestData encoding:NSUTF8StringEncoding];
if (!reqStr) {
[self rt_readRequestFromConnection:conn];
return;
}

NSArray *lines = [reqStr componentsSeparatedByString:@"\r\n"];
if (lines.count == 0) {
[self rt_readRequestFromConnection:conn];
return;
}

NSArray *firstLineParts = [lines[0] componentsSeparatedByString:@" "];
if (firstLineParts.count < 2) {
[self rt_readRequestFromConnection:conn];
return;
}

NSString *method = firstLineParts[0];
NSString *path = firstLineParts[1];

NSRange portRange = FBConfiguration.bindingPortRange;
NSString *urlStr = [NSString stringWithFormat:@"http://127.0.0.1:%lu%@",
(unsigned long)portRange.location, path];
NSURL *url = [NSURL URLWithString:urlStr];
if (!url) {
[self rt_readRequestFromConnection:conn];
return;
}

NSMutableURLRequest *localReq = [NSMutableURLRequest requestWithURL:url];
localReq.HTTPMethod = method;
localReq.timeoutInterval = 60;

NSRange bodyRange = [reqStr rangeOfString:@"\r\n\r\n"];
if (bodyRange.location != NSNotFound) {
NSString *bodyStr = [reqStr substringFromIndex:bodyRange.location + 4];
if (bodyStr.length > 0) {
localReq.HTTPBody = [bodyStr dataUsingEncoding:NSUTF8StringEncoding];
}
}

[[[NSURLSession sharedSession] dataTaskWithRequest:localReq
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method is too complicated. Consider splitting it to smaller parts

completionHandler:^(NSData *data, NSURLResponse *response, NSError *err) {
NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)response;
NSInteger statusCode = httpResp ? httpResp.statusCode : 500;

NSString *statusLine = [NSString stringWithFormat:@"HTTP/1.1 %ld OK\r\n", (long)statusCode];
NSMutableString *respStr = [NSMutableString stringWithString:statusLine];
[respStr appendString:@"Content-Type: application/json\r\n"];
[respStr appendFormat:@"Content-Length: %lu\r\n", (unsigned long)(data ? data.length : 0)];
[respStr appendString:@"\r\n"];

NSMutableData *fullResp = [NSMutableData dataWithData:
[respStr dataUsingEncoding:NSUTF8StringEncoding]];
if (data) {
[fullResp appendData:data];
}

uint32_t respLen = htonl((uint32_t)fullResp.length);
NSMutableData *framed = [NSMutableData dataWithBytes:&respLen length:4];
[framed appendData:fullResp];

dispatch_data_t sendData = dispatch_data_create(
framed.bytes, framed.length,
dispatch_get_global_queue(0, 0),
DISPATCH_DATA_DESTRUCTOR_DEFAULT
);
nw_connection_send(conn, sendData, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, true,
^(nw_error_t sendError) {
if (sendError) {
[FBLogger logFmt:@"[ReverseTunnel] Send error: %@", sendError];
}
[self rt_readRequestFromConnection:conn];
});
}] resume];
}

@end
16 changes: 16 additions & 0 deletions WebDriverAgentLib/Utilities/FBConfiguration.h
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ extern NSString *const FBSnapshotMaxDepthKey;
+ (CGFloat)mjpegScalingFactor;
+ (void)setMjpegScalingFactor:(CGFloat)scalingFactor;

/**
The host address of an external relay server for reverse TCP tunnel.
When set via WDA_RELAY_HOST environment variable, WDA will actively connect
outbound to this relay instead of only listening for inbound connections.
This enables WDA control in NAT-restricted environments (symmetric NAT,
multi-layer firewalls, VPN tunnels) where inbound connections to the device
are not feasible. Returns nil if not configured (default behavior unchanged).
*/
+ (NSString * _Nullable)relayHost;

/**
The port of the external relay server.
Configured via WDA_RELAY_PORT environment variable. Defaults to 8201.
*/
+ (NSInteger)relayPort;

/**
YES if verbose logging is enabled. NO otherwise.
*/
Expand Down
18 changes: 18 additions & 0 deletions WebDriverAgentLib/Utilities/FBConfiguration.m
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,24 @@ + (void)setMjpegShouldFixOrientation:(BOOL)enabled {
FBMjpegShouldFixOrientation = enabled;
}

+ (NSString *)relayHost
{
NSString *host = NSProcessInfo.processInfo.environment[@"WDA_RELAY_HOST"];
if (host && [host length] > 0) {
return host;
}
return nil;
}

+ (NSInteger)relayPort
{
NSString *port = NSProcessInfo.processInfo.environment[@"WDA_RELAY_PORT"];
if (port && [port length] > 0) {
return [port integerValue];
}
return 8201;
}

+ (BOOL)verboseLoggingEnabled
{
return [NSProcessInfo.processInfo.environment[@"VERBOSE_LOGGING"] boolValue];
Expand Down