From 7426e66efb1b0587f9b0f5bba60f83e14025d206 Mon Sep 17 00:00:00 2001 From: dankefox Date: Sun, 26 Apr 2026 03:21:24 +0800 Subject: [PATCH] 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 --- Scripts/wda-relay-server.js | 122 ++++++++++++ WebDriverAgentLib/Routing/FBWebServer.m | 186 ++++++++++++++++++ WebDriverAgentLib/Utilities/FBConfiguration.h | 16 ++ WebDriverAgentLib/Utilities/FBConfiguration.m | 18 ++ 4 files changed, 342 insertions(+) create mode 100755 Scripts/wda-relay-server.js diff --git a/Scripts/wda-relay-server.js b/Scripts/wda-relay-server.js new file mode 100755 index 000000000..639ede0e7 --- /dev/null +++ b/Scripts/wda-relay-server.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * 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= 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= WDA_RELAY_PORT=${RELAY_PORT} xcodebuild test-without-building ...`); + console.log(` curl http://localhost:${PROXY_PORT}/status`); +}); diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index 29a2e16e6..b30f5a10d 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -22,6 +22,7 @@ #import "FBUnknownCommands.h" #import "FBConfiguration.h" #import "FBLogger.h" +#import #import "XCUIDevice+FBHelpers.h" @@ -78,6 +79,7 @@ - (void)startServing self.exceptionHandler = [FBExceptionHandler new]; [self startHTTPServer]; [self initScreenshotsBroadcaster]; + [self startReverseTunnel]; self.keepAlive = YES; NSRunLoop *runLoop = [NSRunLoop mainRunLoop]; @@ -274,4 +276,188 @@ - (void)registerServerKeyRouteHandlers [self registerRouteHandlers:@[FBUnknownCommands.class]]; } + +#pragma mark - Reverse TCP Tunnel + +- (void)startReverseTunnel +{ + 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) { + [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 + 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 diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.h b/WebDriverAgentLib/Utilities/FBConfiguration.h index e8c7754bf..2e2927a80 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.h +++ b/WebDriverAgentLib/Utilities/FBConfiguration.h @@ -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. */ diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.m b/WebDriverAgentLib/Utilities/FBConfiguration.m index dcd1a62e3..d8bec839a 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.m +++ b/WebDriverAgentLib/Utilities/FBConfiguration.m @@ -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];