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
42 changes: 42 additions & 0 deletions WebDriverAgentLib/Routing/FBReverseTunnel.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* 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 <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

/**
Optional reverse TCP tunnel for NAT-restricted environments.

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 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.

Connection failures trigger automatic reconnection after a configurable delay.
*/
@interface FBReverseTunnel : NSObject

/**
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)startWithHost:(NSString *)host
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WDA also provides shutdown endpoint. Consider stopping this server as well when the main web server is stopped

port:(NSInteger)port
localPort:(NSUInteger)localPort;

@end

NS_ASSUME_NONNULL_END
233 changes: 233 additions & 0 deletions WebDriverAgentLib/Routing/FBReverseTunnel.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* 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 <Network/Network.h>
#import <sys/socket.h>
#import <netinet/in.h>
#import "FBLogger.h"

/** Maximum allowed payload size (matches Appium HTTP server limit) */
static const uint32_t FBReverseTunnelMaxPayloadSize = 1024 * 1024 * 1024; // 1 GB

/** 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

consider making these to class properties. Avoid using static as it makes the entity less flexible

static NSInteger _relayPort;
static NSUInteger _localPort;
static uint64_t _currentReconnectDelay;

@implementation FBReverseTunnel

#pragma mark - Public

+ (void)startWithHost:(NSString *)host
port:(NSInteger)port
localPort:(NSUInteger)localPort
{
_relayHost = host;
_relayPort = port;
_localPort = localPort;
_currentReconnectDelay = FBReverseTunnelInitialReconnectDelay;
[self connect];
}

#pragma mark - Connection Management

+ (void)connect
{
[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));

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"];
_currentReconnectDelay = FBReverseTunnelInitialReconnectDelay; // reset backoff on success
[self readFrameFromConnection:conn];
break;
case nw_connection_state_failed:
[FBLogger logFmt:@"[ReverseTunnel] Connection failed: %@, retrying in %llus",
error, FBReverseTunnelReconnectDelay];
[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: %@", error];
break;
case nw_connection_state_preparing:
[FBLogger logFmt:@"[ReverseTunnel] Preparing..."];
break;
default:
break;
}
});

nw_connection_start(conn);
}

+ (void)scheduleReconnect
{
uint64_t delay = _currentReconnectDelay;
[FBLogger logFmt:@"[ReverseTunnel] Reconnecting in %llus (backoff)", delay];
dispatch_after(
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, 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"];
nw_connection_cancel(conn);
[self scheduleReconnect];
return;
}

__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 >= 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];
}
return true;
});

if (payloadLen == 0 || payloadLen > FBReverseTunnelMaxPayloadSize) {
[FBLogger logFmt:@"[ReverseTunnel] Invalid payload length: %u, skipping", payloadLen];
[self readFrameFromConnection:conn];
return;
}

[self readPayload:payloadLen requestId:reqId fromConnection: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"];
nw_connection_cancel(conn);
[self scheduleReconnect];
return;
}

NSData *requestData = [self extractData:bodyData];
[self forwardRequest:requestData requestId:reqId 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 Forwarding (POSIX socket to localhost)

+ (void)forwardRequest:(NSData *)httpRequest
requestId:(uint32_t)reqId
throughConnection:(nw_connection_t)conn
{
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[FBReverseTunnelRecvBufferSize];
while (1) {
ssize_t n = recv(sock, buf, FBReverseTunnelRecvBufferSize, 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)];
}
close(sock);

[self sendResponse:response requestId:reqId throughConnection:conn];
});
}

#pragma mark - Response Framing & Sending

+ (void)sendResponse:(NSData *)response
requestId:(uint32_t)reqId
throughConnection:(nw_connection_t)conn
{
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];
});
}

@end
47 changes: 47 additions & 0 deletions WebDriverAgentLib/Routing/FBWebServer.m
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
#import "FBUnknownCommands.h"
#import "FBConfiguration.h"
#import "FBLogger.h"
#import "FBReverseTunnel.h"
#import <Network/Network.h>
#import <signal.h>

#import "XCUIDevice+FBHelpers.h"

Expand All @@ -48,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
Expand All @@ -74,11 +78,54 @@ - (void)dealloc

- (void)startServing
{
// Ignore SIGTERM/SIGHUP to survive IDE disconnection (enables wireless operation)
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 is a breaking change, please revert

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

// Start reverse tunnel if configured
NSString *relayHost = FBConfiguration.relayHost;
Copy link
Copy Markdown

@mykola-mokhnach mykola-mokhnach Apr 26, 2026

Choose a reason for hiding this comment

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

Please extract this part into a separate private method

if (relayHost) {
[FBReverseTunnel startWithHost:relayHost
port:FBConfiguration.relayPort
localPort:FBConfiguration.bindingPortRange.location];
}

// Network change monitor - restart HTTP server on interface changes
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 is also a breaking change, please revert

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];
while (self.keepAlive &&
Expand Down
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
Loading
Loading