From 24f73f18e7a45b67d01a4177178b6d17358db52d Mon Sep 17 00:00:00 2001 From: dankefox <245899688+dankefox@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:17:32 +0800 Subject: [PATCH 1/4] feat: optional reverse TCP tunnel for WDA in NAT-restricted environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Changes based on review feedback: - Extracted reverse tunnel into dedicated FBReverseTunnel module - Split complex methods into focused, single-responsibility functions - Extracted magic numbers into named constants - FBWebServer only has a single-line call to FBReverseTunnel Implementation notes: - Uses Network.framework (nw_connection) for outbound TCP — tested and verified after NSStream and POSIX sockets both proved unreliable for outbound connections over VPN/tunnel interfaces on iOS - 4-byte big-endian length-prefixed framing for minimal overhead - Auto-reconnect with configurable delay on connection failure Includes: - FBConfiguration: relay host/port accessors from env vars - FBReverseTunnel: standalone reverse tunnel module - Scripts/wda-relay-server.js: reference relay server implementation (required counterpart — users need a relay to connect to) --- Scripts/wda-relay-server.js | 122 ++++++++ WebDriverAgentLib/Routing/FBReverseTunnel.h | 38 +++ WebDriverAgentLib/Routing/FBReverseTunnel.m | 276 ++++++++++++++++++ WebDriverAgentLib/Routing/FBWebServer.m | 2 + WebDriverAgentLib/Utilities/FBConfiguration.h | 16 + WebDriverAgentLib/Utilities/FBConfiguration.m | 18 ++ 6 files changed, 472 insertions(+) create mode 100755 Scripts/wda-relay-server.js create mode 100644 WebDriverAgentLib/Routing/FBReverseTunnel.h create mode 100644 WebDriverAgentLib/Routing/FBReverseTunnel.m 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/FBReverseTunnel.h b/WebDriverAgentLib/Routing/FBReverseTunnel.h new file mode 100644 index 000000000..0703142b9 --- /dev/null +++ b/WebDriverAgentLib/Routing/FBReverseTunnel.h @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Optional reverse TCP tunnel for NAT-restricted environments. + + When WDA_RELAY_HOST is set, this module opens an outbound TCP connection + to an external relay server, allowing WDA to be controlled in environments + where inbound connections to port 8100 are not feasible (symmetric NAT, + multi-layer firewalls, VPN tunnels, etc.). + + The tunnel uses a simple 4-byte big-endian length-prefixed framing protocol + to multiplex HTTP request/response pairs over a single persistent connection. + + When WDA_RELAY_HOST is not set, this module is completely inactive. + */ +@interface FBReverseTunnel : NSObject + +/** + Starts the reverse tunnel if WDA_RELAY_HOST is configured. + Does nothing if the environment variable is not set (default behavior unchanged). + + @param localPort The local WDA HTTP server port to forward requests to + */ ++ (void)startIfConfiguredWithLocalPort:(NSUInteger)localPort; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Routing/FBReverseTunnel.m b/WebDriverAgentLib/Routing/FBReverseTunnel.m new file mode 100644 index 000000000..5d48b8f76 --- /dev/null +++ b/WebDriverAgentLib/Routing/FBReverseTunnel.m @@ -0,0 +1,276 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBReverseTunnel.h" +#import +#import "FBConfiguration.h" +#import "FBLogger.h" + +/** Maximum allowed payload size to guard against corrupted length headers */ +static const uint32_t FBReverseTunnelMaxPayloadSize = 10 * 1024 * 1024; // 10 MB + +/** Delay before reconnecting after a connection failure */ +static const uint64_t FBReverseTunnelReconnectDelay = 5; // seconds + +static NSUInteger _localPort; + +@implementation FBReverseTunnel + +#pragma mark - Public + ++ (void)startIfConfiguredWithLocalPort:(NSUInteger)localPort +{ + NSString *relayHost = FBConfiguration.relayHost; + if (!relayHost) { + return; // Reverse tunnel not configured — default behavior unchanged + } + _localPort = localPort; + [self connectToRelayHost:relayHost port:FBConfiguration.relayPort]; +} + +#pragma mark - Connection Management + ++ (void)connectToRelayHost:(NSString *)host port:(NSInteger)port +{ + [FBLogger logFmt:@"[ReverseTunnel] Connecting to relay %@:%ld", host, (long)port]; + + nw_endpoint_t endpoint = nw_endpoint_create_host( + [host UTF8String], + [[NSString stringWithFormat:@"%ld", (long)port] 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)); + + 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"]; + [self readFrameFromConnection:conn]; + break; + case nw_connection_state_failed: + [FBLogger logFmt:@"[ReverseTunnel] Connection failed: %@, retrying in %llus", + error, FBReverseTunnelReconnectDelay]; + [self scheduleReconnectToHost:host port:port]; + break; + case nw_connection_state_waiting: + [FBLogger logFmt:@"[ReverseTunnel] Waiting for network path"]; + break; + default: + break; + } + }); + + nw_connection_start(conn); +} + ++ (void)scheduleReconnectToHost:(NSString *)host port:(NSInteger)port +{ + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(FBReverseTunnelReconnectDelay * NSEC_PER_SEC)), + dispatch_get_global_queue(0, 0), + ^{ [self connectToRelayHost:host port:port]; } + ); +} + ++ (void)handleDisconnection:(nw_connection_t)conn +{ + nw_connection_cancel(conn); + NSString *host = FBConfiguration.relayHost; + if (host) { + [self scheduleReconnectToHost:host port:FBConfiguration.relayPort]; + } +} + +#pragma mark - Frame Reading (4-byte length-prefixed protocol) + ++ (void)readFrameFromConnection:(nw_connection_t)conn +{ + // Read 4-byte big-endian length header + 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] Header read error, reconnecting"]; + [self handleDisconnection:conn]; + return; + } + + uint32_t payloadLen = [self parsePayloadLength:lenData]; + if (payloadLen == 0 || payloadLen > FBReverseTunnelMaxPayloadSize) { + [FBLogger logFmt:@"[ReverseTunnel] Invalid payload length: %u, skipping", payloadLen]; + [self readFrameFromConnection:conn]; + return; + } + + [self readPayload:payloadLen fromConnection:conn]; + }); +} + ++ (uint32_t)parsePayloadLength:(dispatch_data_t)data +{ + __block uint32_t length = 0; + dispatch_data_apply(data, ^bool(dispatch_data_t region, size_t offset, + const void *buffer, size_t size) { + if (size >= 4) { + memcpy(&length, buffer, 4); + length = ntohl(length); + } + return true; + }); + return length; +} + ++ (void)readPayload:(uint32_t)length fromConnection:(nw_connection_t)conn +{ + nw_connection_receive(conn, length, length, ^(dispatch_data_t bodyData, + nw_content_context_t ctx, + bool isComplete, nw_error_t error) { + if (error || !bodyData) { + [FBLogger logFmt:@"[ReverseTunnel] Payload read error, reconnecting"]; + [self handleDisconnection:conn]; + return; + } + + NSData *requestData = [self extractData:bodyData]; + [self forwardHTTPRequest:requestData throughConnection:conn]; + }); +} + ++ (NSData *)extractData:(dispatch_data_t)dispatchData +{ + NSMutableData *result = [NSMutableData data]; + dispatch_data_apply(dispatchData, ^bool(dispatch_data_t region, size_t offset, + const void *buffer, size_t size) { + [result appendBytes:buffer length:size]; + return true; + }); + return result; +} + +#pragma mark - HTTP Request Parsing + ++ (NSDictionary *)parseHTTPRequest:(NSData *)data +{ + NSString *raw = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (!raw) return nil; + + NSArray *lines = [raw componentsSeparatedByString:@"\r\n"]; + if (lines.count == 0) return nil; + + NSArray *requestLine = [lines[0] componentsSeparatedByString:@" "]; + if (requestLine.count < 2) return nil; + + NSString *method = requestLine[0]; + NSString *path = requestLine[1]; + + // Extract body after \r\n\r\n + NSData *body = nil; + NSRange separator = [raw rangeOfString:@"\r\n\r\n"]; + if (separator.location != NSNotFound) { + NSString *bodyStr = [raw substringFromIndex:separator.location + 4]; + if (bodyStr.length > 0) { + body = [bodyStr dataUsingEncoding:NSUTF8StringEncoding]; + } + } + + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + result[@"method"] = method; + result[@"path"] = path; + if (body) result[@"body"] = body; + return result; +} + +#pragma mark - HTTP Forwarding + ++ (void)forwardHTTPRequest:(NSData *)requestData throughConnection:(nw_connection_t)conn +{ + NSDictionary *parsed = [self parseHTTPRequest:requestData]; + if (!parsed) { + [self readFrameFromConnection:conn]; + return; + } + + NSURLRequest *localRequest = [self buildLocalRequestWithMethod:parsed[@"method"] + path:parsed[@"path"] + body:parsed[@"body"]]; + if (!localRequest) { + [self readFrameFromConnection:conn]; + return; + } + + [[[NSURLSession sharedSession] dataTaskWithRequest:localRequest + completionHandler:^(NSData *data, NSURLResponse *response, NSError *err) { + NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)response; + NSInteger statusCode = httpResp ? httpResp.statusCode : 500; + + NSData *framedResponse = [self buildFramedResponse:data statusCode:statusCode]; + [self sendData:framedResponse throughConnection:conn]; + }] resume]; +} + ++ (NSURLRequest *)buildLocalRequestWithMethod:(NSString *)method + path:(NSString *)path + body:(NSData *)body +{ + NSString *urlStr = [NSString stringWithFormat:@"http://127.0.0.1:%lu%@", + (unsigned long)_localPort, path]; + NSURL *url = [NSURL URLWithString:urlStr]; + if (!url) return nil; + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = method; + request.timeoutInterval = 60; + if (body) { + request.HTTPBody = body; + } + return request; +} + +#pragma mark - Response Building & Sending + ++ (NSData *)buildFramedResponse:(NSData *)body statusCode:(NSInteger)statusCode +{ + NSString *statusLine = [NSString stringWithFormat:@"HTTP/1.1 %ld OK\r\n", (long)statusCode]; + NSMutableString *headers = [NSMutableString stringWithString:statusLine]; + [headers appendString:@"Content-Type: application/json\r\n"]; + [headers appendFormat:@"Content-Length: %lu\r\n", (unsigned long)(body ? body.length : 0)]; + [headers appendString:@"\r\n"]; + + NSMutableData *httpResponse = [NSMutableData dataWithData: + [headers dataUsingEncoding:NSUTF8StringEncoding]]; + if (body) { + [httpResponse appendData:body]; + } + + // Add 4-byte length prefix + uint32_t framedLen = htonl((uint32_t)httpResponse.length); + NSMutableData *framed = [NSMutableData dataWithBytes:&framedLen length:4]; + [framed appendData:httpResponse]; + return framed; +} + ++ (void)sendData:(NSData *)data throughConnection:(nw_connection_t)conn +{ + dispatch_data_t sendData = dispatch_data_create( + data.bytes, data.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 readFrameFromConnection:conn]; + }); +} + +@end diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index 29a2e16e6..e458b93f6 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 "FBReverseTunnel.h" #import "XCUIDevice+FBHelpers.h" @@ -78,6 +79,7 @@ - (void)startServing self.exceptionHandler = [FBExceptionHandler new]; [self startHTTPServer]; [self initScreenshotsBroadcaster]; + [FBReverseTunnel startIfConfiguredWithLocalPort:FBConfiguration.bindingPortRange.location]; self.keepAlive = YES; NSRunLoop *runLoop = [NSRunLoop mainRunLoop]; 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]; From abafbc5a76fce71b7a7821b6fba1bb2329111966 Mon Sep 17 00:00:00 2001 From: dankefox <245899688+dankefox@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:38:49 +0800 Subject: [PATCH 2/4] refactor: move relay server to docs/, convert to ESM, extract default port constant - Move wda-relay-server.js from Scripts/ to docs/ as reference implementation - Convert to ESM format (.mjs) with node: protocol imports - Replace Buffer.slice() with Buffer.subarray() (non-deprecated) - Extract DefaultRelayPort constant in FBConfiguration.m Addresses review feedback from @KazuCocoa and @mykola-mokhnach --- WebDriverAgentLib/Utilities/FBConfiguration.m | 3 ++- .../wda-relay-server.mjs | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) rename Scripts/wda-relay-server.js => docs/wda-relay-server.mjs (85%) mode change 100755 => 100644 diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.m b/WebDriverAgentLib/Utilities/FBConfiguration.m index d8bec839a..ebddd3046 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.m +++ b/WebDriverAgentLib/Utilities/FBConfiguration.m @@ -26,6 +26,7 @@ static NSUInteger const DefaultStartingPort = 8100; static NSUInteger const DefaultMjpegServerPort = 9100; static NSUInteger const DefaultPortRange = 100; +static NSUInteger const DefaultRelayPort = 8201; static char const *const controllerPrefBundlePath = "/System/Library/PrivateFrameworks/TextInput.framework/TextInput"; static NSString *const controllerClassName = @"TIPreferencesController"; @@ -196,7 +197,7 @@ + (NSInteger)relayPort if (port && [port length] > 0) { return [port integerValue]; } - return 8201; + return DefaultRelayPort; } + (BOOL)verboseLoggingEnabled diff --git a/Scripts/wda-relay-server.js b/docs/wda-relay-server.mjs old mode 100755 new mode 100644 similarity index 85% rename from Scripts/wda-relay-server.js rename to docs/wda-relay-server.mjs index 639ede0e7..daca474a1 --- a/Scripts/wda-relay-server.js +++ b/docs/wda-relay-server.mjs @@ -8,8 +8,8 @@ * * 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 + * node wda-relay-server.mjs # relay on 8201, proxy on 8100 + * node wda-relay-server.mjs 9201 9100 # custom ports * * Protocol (between relay and WDA): * [4-byte big-endian length][payload] @@ -17,14 +17,17 @@ * Response payload: raw HTTP response (status + headers + body) */ -const net = require('net'); -const http = require('http'); +import net from 'node:net'; +import http from 'node:http'; -const RELAY_PORT = parseInt(process.argv[2]) || 8201; -const PROXY_PORT = parseInt(process.argv[3]) || 8100; +const DEFAULT_RELAY_PORT = 8201; +const DEFAULT_PROXY_PORT = 8100; + +const RELAY_PORT = parseInt(process.argv[2]) || DEFAULT_RELAY_PORT; +const PROXY_PORT = parseInt(process.argv[3]) || DEFAULT_PROXY_PORT; let wdaSocket = null; -let pendingRequests = new Map(); +const pendingRequests = new Map(); let requestCounter = 0; // --- Relay server: accepts reverse connection from WDA --- @@ -41,8 +44,8 @@ const relayServer = net.createServer((socket) => { const len = buffer.readUInt32BE(0); if (buffer.length < 4 + len) break; - const payload = buffer.slice(4, 4 + len); - buffer = buffer.slice(4 + len); + const payload = buffer.subarray(4, 4 + len); + buffer = buffer.subarray(4 + len); // Route response to the oldest pending HTTP request const oldest = pendingRequests.entries().next().value; @@ -55,7 +58,7 @@ const relayServer = net.createServer((socket) => { if (headerEnd !== -1) { const statusMatch = text.match(/^HTTP\/\d\.\d (\d+)/); const statusCode = statusMatch ? parseInt(statusMatch[1]) : 200; - const body = payload.slice(headerEnd + 4); + const body = payload.subarray(headerEnd + 4); res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(body); } else { @@ -85,7 +88,7 @@ const proxyServer = http.createServer((req, res) => { return; } - let body = []; + const body = []; req.on('data', (chunk) => body.push(chunk)); req.on('end', () => { const bodyBuf = Buffer.concat(body); From e462c4127f0d808e32355931d0d0e9a10fd18e88 Mon Sep 17 00:00:00 2001 From: dankefox <245899688+dankefox@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:53:47 +0800 Subject: [PATCH 3/4] refactor: align with tested implementation, address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use 8-byte header protocol (4-byte length + 4-byte request ID) for reliable request-response correlation, matching tested implementation - Use POSIX socket forwarding to localhost for raw HTTP relay - Accept host/port as explicit parameters instead of reading FBConfiguration internally (addresses @mykola-mokhnach feedback on module coupling) - Increase max payload size from 10 MB to 1 GB to match Appium HTTP server limit, supporting large screenshots and video captures - Add SIGTERM/SIGHUP signal handling in FBWebServer to survive IDE disconnection (enables wireless-only operation) - Add network path monitor to automatically restart HTTP server on network interface changes (WiFi ↔ cellular transitions) - Update relay server to match 8-byte header protocol with reqId-based response routing Map is the correct data structure for pendingRequests because responses are now routed by request ID rather than FIFO order. --- WebDriverAgentLib/Routing/FBReverseTunnel.h | 24 +- WebDriverAgentLib/Routing/FBReverseTunnel.m | 245 ++++++++------------ WebDriverAgentLib/Routing/FBWebServer.m | 47 +++- docs/wda-relay-server.mjs | 50 ++-- 4 files changed, 181 insertions(+), 185 deletions(-) diff --git a/WebDriverAgentLib/Routing/FBReverseTunnel.h b/WebDriverAgentLib/Routing/FBReverseTunnel.h index 0703142b9..3d356bd06 100644 --- a/WebDriverAgentLib/Routing/FBReverseTunnel.h +++ b/WebDriverAgentLib/Routing/FBReverseTunnel.h @@ -13,25 +13,29 @@ NS_ASSUME_NONNULL_BEGIN /** Optional reverse TCP tunnel for NAT-restricted environments. - When WDA_RELAY_HOST is set, this module opens an outbound TCP connection - to an external relay server, allowing WDA to be controlled in environments - where inbound connections to port 8100 are not feasible (symmetric NAT, - multi-layer firewalls, VPN tunnels, etc.). + When configured, this module opens an outbound TCP connection to an external + relay server, allowing WDA to be controlled in environments where inbound + connections to port 8100 are not feasible (symmetric NAT, multi-layer + firewalls, cellular networks, VPN tunnels, etc.). - The tunnel uses a simple 4-byte big-endian length-prefixed framing protocol - to multiplex HTTP request/response pairs over a single persistent connection. + The tunnel uses an 8-byte header framing protocol (4-byte payload length + + 4-byte request ID, both big-endian) to multiplex HTTP request/response pairs + over a single persistent connection with reliable request-response correlation. - When WDA_RELAY_HOST is not set, this module is completely inactive. + Connection failures trigger automatic reconnection after a configurable delay. */ @interface FBReverseTunnel : NSObject /** - Starts the reverse tunnel if WDA_RELAY_HOST is configured. - Does nothing if the environment variable is not set (default behavior unchanged). + Starts the reverse tunnel to the specified relay host and port. + @param host The relay server hostname or IP address + @param port The relay server port @param localPort The local WDA HTTP server port to forward requests to */ -+ (void)startIfConfiguredWithLocalPort:(NSUInteger)localPort; ++ (void)startWithHost:(NSString *)host + port:(NSInteger)port + localPort:(NSUInteger)localPort; @end diff --git a/WebDriverAgentLib/Routing/FBReverseTunnel.m b/WebDriverAgentLib/Routing/FBReverseTunnel.m index 5d48b8f76..4b7ebf9a5 100644 --- a/WebDriverAgentLib/Routing/FBReverseTunnel.m +++ b/WebDriverAgentLib/Routing/FBReverseTunnel.m @@ -8,40 +8,43 @@ #import "FBReverseTunnel.h" #import -#import "FBConfiguration.h" +#import +#import #import "FBLogger.h" -/** Maximum allowed payload size to guard against corrupted length headers */ -static const uint32_t FBReverseTunnelMaxPayloadSize = 10 * 1024 * 1024; // 10 MB +/** Maximum allowed payload size (matches Appium HTTP server limit) */ +static const uint32_t FBReverseTunnelMaxPayloadSize = 1024 * 1024 * 1024; // 1 GB /** Delay before reconnecting after a connection failure */ static const uint64_t FBReverseTunnelReconnectDelay = 5; // seconds +static NSString *_relayHost; +static NSInteger _relayPort; static NSUInteger _localPort; @implementation FBReverseTunnel #pragma mark - Public -+ (void)startIfConfiguredWithLocalPort:(NSUInteger)localPort ++ (void)startWithHost:(NSString *)host + port:(NSInteger)port + localPort:(NSUInteger)localPort { - NSString *relayHost = FBConfiguration.relayHost; - if (!relayHost) { - return; // Reverse tunnel not configured — default behavior unchanged - } + _relayHost = host; + _relayPort = port; _localPort = localPort; - [self connectToRelayHost:relayHost port:FBConfiguration.relayPort]; + [self connect]; } #pragma mark - Connection Management -+ (void)connectToRelayHost:(NSString *)host port:(NSInteger)port ++ (void)connect { - [FBLogger logFmt:@"[ReverseTunnel] Connecting to relay %@:%ld", host, (long)port]; + [FBLogger logFmt:@"[ReverseTunnel] Connecting to relay %@:%ld", _relayHost, (long)_relayPort]; nw_endpoint_t endpoint = nw_endpoint_create_host( - [host UTF8String], - [[NSString stringWithFormat:@"%ld", (long)port] UTF8String] + [_relayHost UTF8String], + [[NSString stringWithFormat:@"%ld", (long)_relayPort] UTF8String] ); nw_parameters_t params = nw_parameters_create_secure_tcp( NW_PARAMETERS_DISABLE_PROTOCOL, @@ -59,10 +62,16 @@ + (void)connectToRelayHost:(NSString *)host port:(NSInteger)port case nw_connection_state_failed: [FBLogger logFmt:@"[ReverseTunnel] Connection failed: %@, retrying in %llus", error, FBReverseTunnelReconnectDelay]; - [self scheduleReconnectToHost:host port:port]; + [self scheduleReconnect]; + break; + case nw_connection_state_cancelled: + [FBLogger logFmt:@"[ReverseTunnel] Connection cancelled"]; break; case nw_connection_state_waiting: - [FBLogger logFmt:@"[ReverseTunnel] Waiting for network path"]; + [FBLogger logFmt:@"[ReverseTunnel] Waiting for network path: %@", error]; + break; + case nw_connection_state_preparing: + [FBLogger logFmt:@"[ReverseTunnel] Preparing..."]; break; default: break; @@ -72,75 +81,65 @@ + (void)connectToRelayHost:(NSString *)host port:(NSInteger)port nw_connection_start(conn); } -+ (void)scheduleReconnectToHost:(NSString *)host port:(NSInteger)port ++ (void)scheduleReconnect { dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(FBReverseTunnelReconnectDelay * NSEC_PER_SEC)), dispatch_get_global_queue(0, 0), - ^{ [self connectToRelayHost:host port:port]; } + ^{ [self connect]; } ); } -+ (void)handleDisconnection:(nw_connection_t)conn -{ - nw_connection_cancel(conn); - NSString *host = FBConfiguration.relayHost; - if (host) { - [self scheduleReconnectToHost:host port:FBConfiguration.relayPort]; - } -} - -#pragma mark - Frame Reading (4-byte length-prefixed protocol) +#pragma mark - Frame Reading (8-byte header: 4-byte length + 4-byte request ID) + (void)readFrameFromConnection:(nw_connection_t)conn { - // Read 4-byte big-endian length header - nw_connection_receive(conn, 4, 4, ^(dispatch_data_t lenData, nw_content_context_t ctx, + nw_connection_receive(conn, 8, 8, ^(dispatch_data_t hdrData, nw_content_context_t ctx, bool isComplete, nw_error_t error) { - if (error || !lenData) { + if (error || !hdrData) { [FBLogger logFmt:@"[ReverseTunnel] Header read error, reconnecting"]; - [self handleDisconnection:conn]; + nw_connection_cancel(conn); + [self scheduleReconnect]; return; } - uint32_t payloadLen = [self parsePayloadLength:lenData]; + __block uint32_t payloadLen = 0, reqId = 0; + dispatch_data_apply(hdrData, ^bool(dispatch_data_t region, size_t offset, + const void *buffer, size_t size) { + const uint8_t *b = buffer; + if (size >= 8) { + payloadLen = (uint32_t)b[0]<<24 | (uint32_t)b[1]<<16 | (uint32_t)b[2]<<8 | b[3]; + reqId = (uint32_t)b[4]<<24 | (uint32_t)b[5]<<16 | (uint32_t)b[6]<<8 | b[7]; + } + return true; + }); + if (payloadLen == 0 || payloadLen > FBReverseTunnelMaxPayloadSize) { [FBLogger logFmt:@"[ReverseTunnel] Invalid payload length: %u, skipping", payloadLen]; [self readFrameFromConnection:conn]; return; } - [self readPayload:payloadLen fromConnection:conn]; - }); -} - -+ (uint32_t)parsePayloadLength:(dispatch_data_t)data -{ - __block uint32_t length = 0; - dispatch_data_apply(data, ^bool(dispatch_data_t region, size_t offset, - const void *buffer, size_t size) { - if (size >= 4) { - memcpy(&length, buffer, 4); - length = ntohl(length); - } - return true; + [self readPayload:payloadLen requestId:reqId fromConnection:conn]; }); - return length; } -+ (void)readPayload:(uint32_t)length fromConnection:(nw_connection_t)conn ++ (void)readPayload:(uint32_t)length + requestId:(uint32_t)reqId + fromConnection:(nw_connection_t)conn { nw_connection_receive(conn, length, length, ^(dispatch_data_t bodyData, nw_content_context_t ctx, bool isComplete, nw_error_t error) { if (error || !bodyData) { [FBLogger logFmt:@"[ReverseTunnel] Payload read error, reconnecting"]; - [self handleDisconnection:conn]; + nw_connection_cancel(conn); + [self scheduleReconnect]; return; } NSData *requestData = [self extractData:bodyData]; - [self forwardHTTPRequest:requestData throughConnection:conn]; + [self forwardRequest:requestData requestId:reqId throughConnection:conn]; }); } @@ -155,119 +154,61 @@ + (NSData *)extractData:(dispatch_data_t)dispatchData return result; } -#pragma mark - HTTP Request Parsing +#pragma mark - HTTP Forwarding (POSIX socket to localhost) -+ (NSDictionary *)parseHTTPRequest:(NSData *)data ++ (void)forwardRequest:(NSData *)httpRequest + requestId:(uint32_t)reqId + throughConnection:(nw_connection_t)conn { - NSString *raw = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - if (!raw) return nil; - - NSArray *lines = [raw componentsSeparatedByString:@"\r\n"]; - if (lines.count == 0) return nil; - - NSArray *requestLine = [lines[0] componentsSeparatedByString:@" "]; - if (requestLine.count < 2) return nil; - - NSString *method = requestLine[0]; - NSString *path = requestLine[1]; - - // Extract body after \r\n\r\n - NSData *body = nil; - NSRange separator = [raw rangeOfString:@"\r\n\r\n"]; - if (separator.location != NSNotFound) { - NSString *bodyStr = [raw substringFromIndex:separator.location + 4]; - if (bodyStr.length > 0) { - body = [bodyStr dataUsingEncoding:NSUTF8StringEncoding]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + int sock = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)_localPort); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + NSMutableData *response = [NSMutableData data]; + if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) == 0) { + send(sock, httpRequest.bytes, httpRequest.length, 0); + + uint8_t buf[65536]; + while (1) { + ssize_t n = recv(sock, buf, sizeof(buf), 0); + if (n <= 0) break; + [response appendBytes:buf length:n]; + } + } else { + const char *err = "HTTP/1.1 502 Bad Gateway\r\n\r\nLocal WDA unreachable"; + [response appendBytes:err length:strlen(err)]; } - } - - NSMutableDictionary *result = [NSMutableDictionary dictionary]; - result[@"method"] = method; - result[@"path"] = path; - if (body) result[@"body"] = body; - return result; -} - -#pragma mark - HTTP Forwarding + close(sock); -+ (void)forwardHTTPRequest:(NSData *)requestData throughConnection:(nw_connection_t)conn -{ - NSDictionary *parsed = [self parseHTTPRequest:requestData]; - if (!parsed) { - [self readFrameFromConnection:conn]; - return; - } - - NSURLRequest *localRequest = [self buildLocalRequestWithMethod:parsed[@"method"] - path:parsed[@"path"] - body:parsed[@"body"]]; - if (!localRequest) { - [self readFrameFromConnection:conn]; - return; - } - - [[[NSURLSession sharedSession] dataTaskWithRequest:localRequest - completionHandler:^(NSData *data, NSURLResponse *response, NSError *err) { - NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)response; - NSInteger statusCode = httpResp ? httpResp.statusCode : 500; - - NSData *framedResponse = [self buildFramedResponse:data statusCode:statusCode]; - [self sendData:framedResponse throughConnection:conn]; - }] resume]; -} - -+ (NSURLRequest *)buildLocalRequestWithMethod:(NSString *)method - path:(NSString *)path - body:(NSData *)body -{ - NSString *urlStr = [NSString stringWithFormat:@"http://127.0.0.1:%lu%@", - (unsigned long)_localPort, path]; - NSURL *url = [NSURL URLWithString:urlStr]; - if (!url) return nil; - - NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; - request.HTTPMethod = method; - request.timeoutInterval = 60; - if (body) { - request.HTTPBody = body; - } - return request; + [self sendResponse:response requestId:reqId throughConnection:conn]; + }); } -#pragma mark - Response Building & Sending +#pragma mark - Response Framing & Sending -+ (NSData *)buildFramedResponse:(NSData *)body statusCode:(NSInteger)statusCode ++ (void)sendResponse:(NSData *)response + requestId:(uint32_t)reqId + throughConnection:(nw_connection_t)conn { - NSString *statusLine = [NSString stringWithFormat:@"HTTP/1.1 %ld OK\r\n", (long)statusCode]; - NSMutableString *headers = [NSMutableString stringWithString:statusLine]; - [headers appendString:@"Content-Type: application/json\r\n"]; - [headers appendFormat:@"Content-Length: %lu\r\n", (unsigned long)(body ? body.length : 0)]; - [headers appendString:@"\r\n"]; - - NSMutableData *httpResponse = [NSMutableData dataWithData: - [headers dataUsingEncoding:NSUTF8StringEncoding]]; - if (body) { - [httpResponse appendData:body]; - } - - // Add 4-byte length prefix - uint32_t framedLen = htonl((uint32_t)httpResponse.length); - NSMutableData *framed = [NSMutableData dataWithBytes:&framedLen length:4]; - [framed appendData:httpResponse]; - return framed; -} - -+ (void)sendData:(NSData *)data throughConnection:(nw_connection_t)conn -{ - dispatch_data_t sendData = dispatch_data_create( - data.bytes, data.length, - dispatch_get_global_queue(0, 0), - DISPATCH_DATA_DESTRUCTOR_DEFAULT - ); - nw_connection_send(conn, sendData, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, true, + uint32_t rLen = (uint32_t)response.length; + uint8_t hdr[8] = { + (rLen>>24)&0xFF, (rLen>>16)&0xFF, (rLen>>8)&0xFF, rLen&0xFF, + (reqId>>24)&0xFF, (reqId>>16)&0xFF, (reqId>>8)&0xFF, reqId&0xFF + }; + + dispatch_data_t hdrOut = dispatch_data_create(hdr, 8, NULL, DISPATCH_DATA_DESTRUCTOR_DEFAULT); + dispatch_data_t bodyOut = dispatch_data_create(response.bytes, response.length, + NULL, DISPATCH_DATA_DESTRUCTOR_DEFAULT); + dispatch_data_t fullOut = dispatch_data_create_concat(hdrOut, bodyOut); + + nw_connection_send(conn, fullOut, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, true, ^(nw_error_t sendError) { if (sendError) { [FBLogger logFmt:@"[ReverseTunnel] Send error: %@", sendError]; + return; } [self readFrameFromConnection:conn]; }); diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index e458b93f6..5f270e99a 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -23,6 +23,8 @@ #import "FBConfiguration.h" #import "FBLogger.h" #import "FBReverseTunnel.h" +#import +#import #import "XCUIDevice+FBHelpers.h" @@ -49,6 +51,7 @@ @interface FBWebServer () @property (atomic, assign) BOOL keepAlive; @property (nonatomic, nullable) FBTCPSocket *screenshotsBroadcaster; @property (nonatomic, nullable, strong) FBMjpegServer *mjpegServer; +@property (nonatomic, strong) nw_path_monitor_t pathMonitor; @end @implementation FBWebServer @@ -75,11 +78,53 @@ - (void)dealloc - (void)startServing { + // Ignore SIGTERM/SIGHUP to survive IDE disconnection (enables wireless operation) + signal(SIGTERM, SIG_IGN); + signal(SIGHUP, SIG_IGN); + [FBLogger logFmt:@"[WDA] SIGTERM/SIGHUP ignored - will survive IDE disconnect"]; + [FBLogger logFmt:@"Built at %s %s", __DATE__, __TIME__]; self.exceptionHandler = [FBExceptionHandler new]; [self startHTTPServer]; [self initScreenshotsBroadcaster]; - [FBReverseTunnel startIfConfiguredWithLocalPort:FBConfiguration.bindingPortRange.location]; + + // Start reverse tunnel if configured + NSString *relayHost = FBConfiguration.relayHost; + if (relayHost) { + [FBReverseTunnel startWithHost:relayHost + port:FBConfiguration.relayPort + localPort:FBConfiguration.bindingPortRange.location]; + } + + // Network change monitor - restart HTTP server on interface changes + self.pathMonitor = nw_path_monitor_create(); + nw_path_monitor_set_queue(self.pathMonitor, + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)); + __block BOOL firstUpdate = YES; + __weak typeof(self) weakSelf = self; + nw_path_monitor_set_update_handler(self.pathMonitor, ^(nw_path_t path) { + if (firstUpdate) { + firstUpdate = NO; + return; + } + if (nw_path_get_status(path) == nw_path_status_satisfied) { + [FBLogger logFmt:@"[WDA] Network changed, restarting HTTP server in 2s..."]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), + dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf && strongSelf.server) { + if (strongSelf.server.isRunning) { + [strongSelf.server stop:NO]; + } + strongSelf.server = nil; + [FBLogger logFmt:@"[WDA] Restarting HTTP server..."]; + [strongSelf startHTTPServer]; + } + }); + } + }); + nw_path_monitor_start(self.pathMonitor); + [FBLogger logFmt:@"[WDA] Network path monitor started"]; self.keepAlive = YES; NSRunLoop *runLoop = [NSRunLoop mainRunLoop]; diff --git a/docs/wda-relay-server.mjs b/docs/wda-relay-server.mjs index daca474a1..54db5130b 100644 --- a/docs/wda-relay-server.mjs +++ b/docs/wda-relay-server.mjs @@ -6,15 +6,15 @@ * and HTTP clients. WDA connects outbound to this relay; HTTP clients connect * to localhost:8100 as usual. * + * Protocol (between relay and WDA): + * [4-byte big-endian payload length][4-byte big-endian request ID][payload] + * Request payload: raw HTTP request (method + headers + body) + * Response payload: raw HTTP response (status + headers + body) + * * Usage: * WDA_RELAY_HOST= WDA_RELAY_PORT=8201 xcodebuild test-without-building ... * node wda-relay-server.mjs # relay on 8201, proxy on 8100 * node wda-relay-server.mjs 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) */ import net from 'node:net'; @@ -27,7 +27,7 @@ const RELAY_PORT = parseInt(process.argv[2]) || DEFAULT_RELAY_PORT; const PROXY_PORT = parseInt(process.argv[3]) || DEFAULT_PROXY_PORT; let wdaSocket = null; -const pendingRequests = new Map(); +const pendingRequests = new Map(); // reqId -> http.ServerResponse let requestCounter = 0; // --- Relay server: accepts reverse connection from WDA --- @@ -40,18 +40,19 @@ const relayServer = net.createServer((socket) => { socket.on('data', (chunk) => { buffer = Buffer.concat([buffer, chunk]); - while (buffer.length >= 4) { - const len = buffer.readUInt32BE(0); - if (buffer.length < 4 + len) break; + // 8-byte header: 4-byte payload length + 4-byte request ID + while (buffer.length >= 8) { + const payloadLen = buffer.readUInt32BE(0); + const reqId = buffer.readUInt32BE(4); + if (buffer.length < 8 + payloadLen) break; - const payload = buffer.subarray(4, 4 + len); - buffer = buffer.subarray(4 + len); + const payload = buffer.subarray(8, 8 + payloadLen); + buffer = buffer.subarray(8 + payloadLen); - // Route response to the oldest pending HTTP request - const oldest = pendingRequests.entries().next().value; - if (oldest) { - const [id, res] = oldest; - pendingRequests.delete(id); + // Route response back to the matching HTTP request + const res = pendingRequests.get(reqId); + if (res) { + pendingRequests.delete(reqId); const text = payload.toString(); const headerEnd = text.indexOf('\r\n\r\n'); @@ -65,6 +66,8 @@ const relayServer = net.createServer((socket) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(payload); } + } else { + console.warn(`[relay] No pending request for reqId ${reqId}`); } } }); @@ -97,16 +100,19 @@ const proxyServer = http.createServer((req, res) => { '\r\n\r\n' + bodyBuf.toString(); const reqBuf = Buffer.from(httpReq); - const lenBuf = Buffer.alloc(4); - lenBuf.writeUInt32BE(reqBuf.length); + const reqId = requestCounter++; + + // 8-byte header: 4-byte payload length + 4-byte request ID + const hdrBuf = Buffer.alloc(8); + hdrBuf.writeUInt32BE(reqBuf.length, 0); + hdrBuf.writeUInt32BE(reqId, 4); - const id = requestCounter++; - pendingRequests.set(id, res); + pendingRequests.set(reqId, res); try { - wdaSocket.write(Buffer.concat([lenBuf, reqBuf])); + wdaSocket.write(Buffer.concat([hdrBuf, reqBuf])); } catch (err) { - pendingRequests.delete(id); + pendingRequests.delete(reqId); res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to forward request' })); } From d909419c4bb1f0a0fa29312b50d13bc0c08d4f72 Mon Sep 17 00:00:00 2001 From: dankefox <245899688+dankefox@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:16:38 +0800 Subject: [PATCH 4/4] refactor: add exponential backoff, extract constants, add documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add exponential backoff for reconnection (5s → 10s → ... → 60s cap), resets on successful connection (circuit breaker) - Extract all magic numbers to named constants: FBReverseTunnelHeaderSize, FBReverseTunnelRecvBufferSize, FBReverseTunnelInitialReconnectDelay, FBReverseTunnelMaxReconnectDelay - Add docs/reverse-tunnel.md with feature overview, architecture diagram, configuration guide, protocol specification, and resilience details - Use POSIX socket for HTTP forwarding: the tunnel relays raw HTTP bytes directly to the local WDA server, avoiding unnecessary parsing/reconstruction that an HTTP client library would require --- WebDriverAgentLib/Routing/FBReverseTunnel.m | 30 ++++-- docs/reverse-tunnel.md | 106 ++++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 docs/reverse-tunnel.md diff --git a/WebDriverAgentLib/Routing/FBReverseTunnel.m b/WebDriverAgentLib/Routing/FBReverseTunnel.m index 4b7ebf9a5..d840c1de3 100644 --- a/WebDriverAgentLib/Routing/FBReverseTunnel.m +++ b/WebDriverAgentLib/Routing/FBReverseTunnel.m @@ -15,12 +15,22 @@ /** Maximum allowed payload size (matches Appium HTTP server limit) */ static const uint32_t FBReverseTunnelMaxPayloadSize = 1024 * 1024 * 1024; // 1 GB -/** Delay before reconnecting after a connection failure */ -static const uint64_t FBReverseTunnelReconnectDelay = 5; // seconds +/** Size of the frame header (4-byte length + 4-byte request ID) */ +static const uint32_t FBReverseTunnelHeaderSize = 8; + +/** Receive buffer size for local HTTP forwarding */ +static const size_t FBReverseTunnelRecvBufferSize = 65536; // 64 KB + +/** Initial delay before reconnecting after a connection failure */ +static const uint64_t FBReverseTunnelInitialReconnectDelay = 5; // seconds + +/** Maximum reconnect delay (exponential backoff cap) */ +static const uint64_t FBReverseTunnelMaxReconnectDelay = 60; // seconds static NSString *_relayHost; static NSInteger _relayPort; static NSUInteger _localPort; +static uint64_t _currentReconnectDelay; @implementation FBReverseTunnel @@ -33,6 +43,7 @@ + (void)startWithHost:(NSString *)host _relayHost = host; _relayPort = port; _localPort = localPort; + _currentReconnectDelay = FBReverseTunnelInitialReconnectDelay; [self connect]; } @@ -57,6 +68,7 @@ + (void)connect switch (state) { case nw_connection_state_ready: [FBLogger logFmt:@"[ReverseTunnel] Connected to relay"]; + _currentReconnectDelay = FBReverseTunnelInitialReconnectDelay; // reset backoff on success [self readFrameFromConnection:conn]; break; case nw_connection_state_failed: @@ -83,18 +95,22 @@ + (void)connect + (void)scheduleReconnect { + uint64_t delay = _currentReconnectDelay; + [FBLogger logFmt:@"[ReverseTunnel] Reconnecting in %llus (backoff)", delay]; dispatch_after( - dispatch_time(DISPATCH_TIME_NOW, (int64_t)(FBReverseTunnelReconnectDelay * NSEC_PER_SEC)), + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_global_queue(0, 0), ^{ [self connect]; } ); + // Exponential backoff: double the delay, cap at max + _currentReconnectDelay = MIN(_currentReconnectDelay * 2, FBReverseTunnelMaxReconnectDelay); } #pragma mark - Frame Reading (8-byte header: 4-byte length + 4-byte request ID) + (void)readFrameFromConnection:(nw_connection_t)conn { - nw_connection_receive(conn, 8, 8, ^(dispatch_data_t hdrData, nw_content_context_t ctx, + nw_connection_receive(conn, FBReverseTunnelHeaderSize, FBReverseTunnelHeaderSize, ^(dispatch_data_t hdrData, nw_content_context_t ctx, bool isComplete, nw_error_t error) { if (error || !hdrData) { [FBLogger logFmt:@"[ReverseTunnel] Header read error, reconnecting"]; @@ -107,7 +123,7 @@ + (void)readFrameFromConnection:(nw_connection_t)conn dispatch_data_apply(hdrData, ^bool(dispatch_data_t region, size_t offset, const void *buffer, size_t size) { const uint8_t *b = buffer; - if (size >= 8) { + if (size >= FBReverseTunnelHeaderSize) { payloadLen = (uint32_t)b[0]<<24 | (uint32_t)b[1]<<16 | (uint32_t)b[2]<<8 | b[3]; reqId = (uint32_t)b[4]<<24 | (uint32_t)b[5]<<16 | (uint32_t)b[6]<<8 | b[7]; } @@ -171,9 +187,9 @@ + (void)forwardRequest:(NSData *)httpRequest if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) == 0) { send(sock, httpRequest.bytes, httpRequest.length, 0); - uint8_t buf[65536]; + uint8_t buf[FBReverseTunnelRecvBufferSize]; while (1) { - ssize_t n = recv(sock, buf, sizeof(buf), 0); + ssize_t n = recv(sock, buf, FBReverseTunnelRecvBufferSize, 0); if (n <= 0) break; [response appendBytes:buf length:n]; } diff --git a/docs/reverse-tunnel.md b/docs/reverse-tunnel.md new file mode 100644 index 000000000..ad2d5732e --- /dev/null +++ b/docs/reverse-tunnel.md @@ -0,0 +1,106 @@ +# Reverse TCP Tunnel for NAT-Restricted Environments + +## Overview + +WebDriverAgent (WDA) normally listens on a TCP port (default 8100) for incoming +HTTP connections. This works when the test client can reach the iOS device +directly, but fails in NAT-restricted environments where inbound connections to +the device are blocked — for example: + +- iOS devices on cellular networks (symmetric NAT) +- Devices behind corporate firewalls with no port forwarding +- Multi-layer VPN tunnels +- Remote device farms without direct network access + +The reverse tunnel feature solves this by having WDA initiate an **outbound** TCP +connection to an external relay server. The relay accepts normal HTTP requests +from Appium clients and forwards them to WDA through the tunnel. + +## Architecture + +``` +Appium Client ──HTTP──▶ Relay Server ◀──TCP (reverse tunnel)── WDA on iOS +(any network) (public IP) (behind NAT) +``` + +1. A relay server runs on a publicly accessible host +2. WDA starts and connects **outbound** to the relay +3. The Appium client sends HTTP requests to the relay +4. The relay forwards requests to WDA through the tunnel and returns responses + +## Configuration + +Set these environment variables **before** launching WDA: + +| Variable | Required | Description | +|---|---|---| +| `WDA_RELAY_HOST` | Yes | Hostname or IP of the relay server | +| `WDA_RELAY_PORT` | No | Relay port (default: 8201) | + +When `WDA_RELAY_HOST` is not set, the reverse tunnel is completely inactive and +WDA behaves exactly as before (zero impact on existing usage). + +### Example: Xcode test launch + +```bash +WDA_RELAY_HOST=relay.example.com WDA_RELAY_PORT=8201 \ + xcodebuild test-without-building \ + -project WebDriverAgent.xcodeproj \ + -scheme WebDriverAgentRunner \ + -destination 'id=DEVICE_UDID' +``` + +### Example: Appium client configuration + +Point `appium:webDriverAgentUrl` at the relay server: + +```json +{ + "appium:webDriverAgentUrl": "http://relay.example.com:8100" +} +``` + +## Running the Relay Server + +A reference relay server implementation is provided at +[`docs/wda-relay-server.mjs`](wda-relay-server.mjs). + +```bash +# Default ports: relay on 8201, HTTP proxy on 8100 +node docs/wda-relay-server.mjs + +# Custom ports +node docs/wda-relay-server.mjs 9201 9100 +``` + +The relay listens on two ports: +- **Relay port** (default 8201): Accepts the reverse TCP connection from WDA +- **Proxy port** (default 8100): Accepts normal HTTP requests from Appium clients + +## Protocol + +The tunnel uses an 8-byte header framing protocol over a single persistent TCP +connection: + +``` +┌──────────────────┬──────────────────┬─────────────┐ +│ Payload Length │ Request ID │ Payload │ +│ (4 bytes, BE) │ (4 bytes, BE) │ (variable) │ +└──────────────────┴──────────────────┴─────────────┘ +``` + +- **Payload Length**: Big-endian uint32, size of the payload in bytes +- **Request ID**: Big-endian uint32, correlates requests with responses +- **Payload**: Raw HTTP request or response bytes + +Request IDs allow reliable request-response correlation even if responses arrive +out of order. + +## Resilience + +- **Automatic reconnection** with exponential backoff (5s → 10s → 20s → ... → 60s cap) +- Backoff resets to 5s after a successful connection +- **SIGTERM/SIGHUP handling**: WDA ignores these signals to survive IDE + disconnection, enabling wireless-only operation +- **Network monitoring**: WDA monitors network path changes and automatically + restarts the HTTP server on interface transitions (e.g., WiFi ↔ cellular)