Skip to content

Commit 24f73f1

Browse files
committed
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. 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)
1 parent 13d61a8 commit 24f73f1

6 files changed

Lines changed: 472 additions & 0 deletions

File tree

Scripts/wda-relay-server.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env node
2+
/**
3+
* WDA Reverse Tunnel Relay Server
4+
*
5+
* This server acts as a bridge between WDA (running on an iOS device behind NAT)
6+
* and HTTP clients. WDA connects outbound to this relay; HTTP clients connect
7+
* to localhost:8100 as usual.
8+
*
9+
* Usage:
10+
* WDA_RELAY_HOST=<this-server-ip> WDA_RELAY_PORT=8201 xcodebuild test-without-building ...
11+
* node wda-relay-server.js # relay on 8201, proxy on 8100
12+
* node wda-relay-server.js 9201 9100 # custom ports
13+
*
14+
* Protocol (between relay and WDA):
15+
* [4-byte big-endian length][payload]
16+
* Request payload: raw HTTP request (method + headers + body)
17+
* Response payload: raw HTTP response (status + headers + body)
18+
*/
19+
20+
const net = require('net');
21+
const http = require('http');
22+
23+
const RELAY_PORT = parseInt(process.argv[2]) || 8201;
24+
const PROXY_PORT = parseInt(process.argv[3]) || 8100;
25+
26+
let wdaSocket = null;
27+
let pendingRequests = new Map();
28+
let requestCounter = 0;
29+
30+
// --- Relay server: accepts reverse connection from WDA ---
31+
const relayServer = net.createServer((socket) => {
32+
console.log(`[relay] WDA connected from ${socket.remoteAddress}`);
33+
wdaSocket = socket;
34+
35+
let buffer = Buffer.alloc(0);
36+
37+
socket.on('data', (chunk) => {
38+
buffer = Buffer.concat([buffer, chunk]);
39+
40+
while (buffer.length >= 4) {
41+
const len = buffer.readUInt32BE(0);
42+
if (buffer.length < 4 + len) break;
43+
44+
const payload = buffer.slice(4, 4 + len);
45+
buffer = buffer.slice(4 + len);
46+
47+
// Route response to the oldest pending HTTP request
48+
const oldest = pendingRequests.entries().next().value;
49+
if (oldest) {
50+
const [id, res] = oldest;
51+
pendingRequests.delete(id);
52+
53+
const text = payload.toString();
54+
const headerEnd = text.indexOf('\r\n\r\n');
55+
if (headerEnd !== -1) {
56+
const statusMatch = text.match(/^HTTP\/\d\.\d (\d+)/);
57+
const statusCode = statusMatch ? parseInt(statusMatch[1]) : 200;
58+
const body = payload.slice(headerEnd + 4);
59+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
60+
res.end(body);
61+
} else {
62+
res.writeHead(200, { 'Content-Type': 'application/json' });
63+
res.end(payload);
64+
}
65+
}
66+
}
67+
});
68+
69+
socket.on('close', () => {
70+
console.log('[relay] WDA disconnected');
71+
wdaSocket = null;
72+
});
73+
74+
socket.on('error', (err) => {
75+
console.error('[relay] Socket error:', err.message);
76+
wdaSocket = null;
77+
});
78+
});
79+
80+
// --- HTTP proxy: accepts normal WDA API requests ---
81+
const proxyServer = http.createServer((req, res) => {
82+
if (!wdaSocket || wdaSocket.destroyed) {
83+
res.writeHead(503, { 'Content-Type': 'application/json' });
84+
res.end(JSON.stringify({ error: 'WDA not connected to relay' }));
85+
return;
86+
}
87+
88+
let body = [];
89+
req.on('data', (chunk) => body.push(chunk));
90+
req.on('end', () => {
91+
const bodyBuf = Buffer.concat(body);
92+
const httpReq = `${req.method} ${req.url} HTTP/1.1\r\nHost: localhost\r\n` +
93+
Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n') +
94+
'\r\n\r\n' + bodyBuf.toString();
95+
96+
const reqBuf = Buffer.from(httpReq);
97+
const lenBuf = Buffer.alloc(4);
98+
lenBuf.writeUInt32BE(reqBuf.length);
99+
100+
const id = requestCounter++;
101+
pendingRequests.set(id, res);
102+
103+
try {
104+
wdaSocket.write(Buffer.concat([lenBuf, reqBuf]));
105+
} catch (err) {
106+
pendingRequests.delete(id);
107+
res.writeHead(502, { 'Content-Type': 'application/json' });
108+
res.end(JSON.stringify({ error: 'Failed to forward request' }));
109+
}
110+
});
111+
});
112+
113+
relayServer.listen(RELAY_PORT, () => {
114+
console.log(`[relay] Waiting for WDA on port ${RELAY_PORT}`);
115+
});
116+
117+
proxyServer.listen(PROXY_PORT, () => {
118+
console.log(`[proxy] HTTP proxy on port ${PROXY_PORT}`);
119+
console.log(`\nUsage: set WDA_RELAY_HOST and WDA_RELAY_PORT env vars when launching WDA`);
120+
console.log(` WDA_RELAY_HOST=<this-ip> WDA_RELAY_PORT=${RELAY_PORT} xcodebuild test-without-building ...`);
121+
console.log(` curl http://localhost:${PROXY_PORT}/status`);
122+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
#import <Foundation/Foundation.h>
10+
11+
NS_ASSUME_NONNULL_BEGIN
12+
13+
/**
14+
Optional reverse TCP tunnel for NAT-restricted environments.
15+
16+
When WDA_RELAY_HOST is set, this module opens an outbound TCP connection
17+
to an external relay server, allowing WDA to be controlled in environments
18+
where inbound connections to port 8100 are not feasible (symmetric NAT,
19+
multi-layer firewalls, VPN tunnels, etc.).
20+
21+
The tunnel uses a simple 4-byte big-endian length-prefixed framing protocol
22+
to multiplex HTTP request/response pairs over a single persistent connection.
23+
24+
When WDA_RELAY_HOST is not set, this module is completely inactive.
25+
*/
26+
@interface FBReverseTunnel : NSObject
27+
28+
/**
29+
Starts the reverse tunnel if WDA_RELAY_HOST is configured.
30+
Does nothing if the environment variable is not set (default behavior unchanged).
31+
32+
@param localPort The local WDA HTTP server port to forward requests to
33+
*/
34+
+ (void)startIfConfiguredWithLocalPort:(NSUInteger)localPort;
35+
36+
@end
37+
38+
NS_ASSUME_NONNULL_END

0 commit comments

Comments
 (0)