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];