From 6fd375ca55c3c8784952fedc2d98953756857689 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:42:28 +0200 Subject: [PATCH 01/23] feat(socks5): vendor hev-socks5-tunnel 2.15.0 as submodule with xcframework build script Co-Authored-By: Claude Fable 5 --- .gitignore | 4 ++ .gitmodules | 3 + Scripts/build-hev-socks5-tunnel.sh | 94 ++++++++++++++++++++++++++++++ ThirdParty/README.md | 20 +++++++ ThirdParty/hev-socks5-tunnel | 1 + 5 files changed, 122 insertions(+) create mode 100644 .gitmodules create mode 100755 Scripts/build-hev-socks5-tunnel.sh create mode 100644 ThirdParty/README.md create mode 160000 ThirdParty/hev-socks5-tunnel diff --git a/.gitignore b/.gitignore index 75359860fc..8b4dc84aa4 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ WebDriverAgentRunner-Runner.app/ # Ruby Gemfile.lock + +# Built from the hev-socks5-tunnel submodule (Scripts/build-hev-socks5-tunnel.sh) +ThirdParty/HevSocks5Tunnel.xcframework/ +ThirdParty/.hev-socks5-tunnel.stamp diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..87f5d8e765 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ThirdParty/hev-socks5-tunnel"] + path = ThirdParty/hev-socks5-tunnel + url = https://github.com/heiher/hev-socks5-tunnel diff --git a/Scripts/build-hev-socks5-tunnel.sh b/Scripts/build-hev-socks5-tunnel.sh new file mode 100755 index 0000000000..e085af3050 --- /dev/null +++ b/Scripts/build-hev-socks5-tunnel.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Build ThirdParty/HevSocks5Tunnel.xcframework from the hev-socks5-tunnel +# git submodule (ThirdParty/hev-socks5-tunnel, pinned by SHA). +# +# Trimmed from upstream build-apple.sh: only the slices WebDriverAgent needs +# (iphoneos-arm64, iphonesimulator-arm64+x86_64). The result is consumed by +# the WebDriverAgentTunnel packet-tunnel appex; see docs/socks5-tunnel.md. +# +# The output is gitignored. Skips the build when the xcframework already +# matches the current submodule SHA + script contents (stamp file). +# +# Usage: Scripts/build-hev-socks5-tunnel.sh [--force] + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SUBMODULE_DIR="$ROOT_DIR/ThirdParty/hev-socks5-tunnel" +OUTPUT="$ROOT_DIR/ThirdParty/HevSocks5Tunnel.xcframework" +STAMP_FILE="$ROOT_DIR/ThirdParty/.hev-socks5-tunnel.stamp" +MIN_IOS="15.0" +JOBS="$(sysctl -n hw.ncpu)" + +if [ ! -f "$SUBMODULE_DIR/src/hev-main.h" ]; then + echo "error: hev-socks5-tunnel submodule is missing or not initialized." >&2 + echo " run: git submodule update --init --recursive" >&2 + exit 1 +fi + +SUBMODULE_SHA=$(git -C "$SUBMODULE_DIR" rev-parse HEAD) +SCRIPT_SHA=$(shasum -a 256 "$0" | cut -d' ' -f1) +STAMP="${SUBMODULE_SHA}-${SCRIPT_SHA}-${MIN_IOS}" + +if [ "${1:-}" != "--force" ] && [ -d "$OUTPUT" ] && [ -f "$STAMP_FILE" ] \ + && [ "$(cat "$STAMP_FILE")" = "$STAMP" ]; then + echo "HevSocks5Tunnel.xcframework is up to date ($SUBMODULE_SHA); skipping build" + exit 0 +fi + +BUILD_DIR=$(mktemp -d -t hev-socks5-tunnel-build) +trap 'rm -rf "$BUILD_DIR"' EXIT + +# Other-platform sources (linux/windows/jni/...) compile to empty objects on +# iOS, producing harmless 'has no symbols' archive warnings; drop just those. +filter_no_symbols() +{ + grep -v 'has no symbols' >&2 || true +} + +# build_static +build_static() +{ + local SDK="$1" ARCH="$2" MIN_FLAG="$3" + echo "building libhev-socks5-tunnel for $SDK/$ARCH" + make -C "$SUBMODULE_DIR" clean >/dev/null + make -C "$SUBMODULE_DIR" -j"$JOBS" \ + PP="xcrun --sdk $SDK --toolchain $SDK clang" \ + CC="xcrun --sdk $SDK --toolchain $SDK clang" \ + CFLAGS="-arch $ARCH $MIN_FLAG" \ + LFLAGS="-arch $ARCH $MIN_FLAG -Wl,-Bsymbolic-functions" static \ + >/dev/null 2> >(filter_no_symbols) + mkdir -p "$BUILD_DIR/$SDK-$ARCH" + # The 'static' target leaves one archive per component; merge them. + libtool -static -o "$BUILD_DIR/$SDK-$ARCH/libhev-socks5-tunnel.a" \ + "$SUBMODULE_DIR/bin/libhev-socks5-tunnel.a" \ + "$SUBMODULE_DIR/third-part/lwip/bin/liblwip.a" \ + "$SUBMODULE_DIR/third-part/yaml/bin/libyaml.a" \ + "$SUBMODULE_DIR/third-part/hev-task-system/bin/libhev-task-system.a" \ + 2> >(filter_no_symbols) + make -C "$SUBMODULE_DIR" clean >/dev/null +} + +build_static iphoneos arm64 "-miphoneos-version-min=$MIN_IOS" +build_static iphonesimulator arm64 "-mios-simulator-version-min=$MIN_IOS" +build_static iphonesimulator x86_64 "-mios-simulator-version-min=$MIN_IOS" + +mkdir -p "$BUILD_DIR/iphonesimulator-universal" +lipo -create \ + "$BUILD_DIR/iphonesimulator-arm64/libhev-socks5-tunnel.a" \ + "$BUILD_DIR/iphonesimulator-x86_64/libhev-socks5-tunnel.a" \ + -output "$BUILD_DIR/iphonesimulator-universal/libhev-socks5-tunnel.a" + +INCLUDE_DIR="$BUILD_DIR/include" +mkdir -p "$INCLUDE_DIR/HevSocks5Tunnel" +cp "$SUBMODULE_DIR/src/hev-main.h" "$INCLUDE_DIR/HevSocks5Tunnel/" +cp "$SUBMODULE_DIR/module.modulemap" "$INCLUDE_DIR/HevSocks5Tunnel/" + +rm -rf "$OUTPUT" +xcodebuild -create-xcframework \ + -library "$BUILD_DIR/iphoneos-arm64/libhev-socks5-tunnel.a" -headers "$INCLUDE_DIR" \ + -library "$BUILD_DIR/iphonesimulator-universal/libhev-socks5-tunnel.a" -headers "$INCLUDE_DIR" \ + -output "$OUTPUT" + +echo "$STAMP" > "$STAMP_FILE" +echo "built $OUTPUT from hev-socks5-tunnel $SUBMODULE_SHA" diff --git a/ThirdParty/README.md b/ThirdParty/README.md new file mode 100644 index 0000000000..3c61de8e30 --- /dev/null +++ b/ThirdParty/README.md @@ -0,0 +1,20 @@ +# ThirdParty + +## hev-socks5-tunnel + +- `hev-socks5-tunnel/` is a git submodule of + [heiher/hev-socks5-tunnel](https://github.com/heiher/hev-socks5-tunnel) + (MIT license), pinned at a release tag. It carries four nested submodules + (hev-task-system, yaml, lwip, hev-socks5-core), so clone/update with + `git submodule update --init --recursive`. +- `HevSocks5Tunnel.xcframework` (gitignored) is built from that source by + `Scripts/build-hev-socks5-tunnel.sh` — a trimmed variant of upstream's + `build-apple.sh` producing only the iphoneos-arm64 and + iphonesimulator-arm64/x86_64 static-library slices. The script skips the + build when a stamp file matches the current submodule SHA; pass `--force` + to rebuild. +- The xcframework is linked only by the `WebDriverAgentTunnel` packet-tunnel + app extension; see `docs/socks5-tunnel.md`. + +To bump the engine: check out a new tag inside the submodule, update the +nested submodules, commit the new SHA, and rerun the build script. diff --git a/ThirdParty/hev-socks5-tunnel b/ThirdParty/hev-socks5-tunnel new file mode 160000 index 0000000000..00c7eb9ad7 --- /dev/null +++ b/ThirdParty/hev-socks5-tunnel @@ -0,0 +1 @@ +Subproject commit 00c7eb9ad7ca381b0f1fee880abc1077fe9b93be From 6350bb35be2cd16781c70b7b7411f723bcb23be2 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:51:15 +0200 Subject: [PATCH 02/23] feat(socks5): add SOCKS5 URI parser and shared tunnel protocol with hev YAML builder Co-Authored-By: Claude Fable 5 --- WebDriverAgent.xcodeproj/project.pbxproj | 32 +++++ .../Utilities/FBSocks5TunnelProtocol.h | 68 ++++++++++ .../Utilities/FBSocks5TunnelProtocol.m | 78 ++++++++++++ WebDriverAgentLib/Utilities/FBSocks5URI.h | 48 +++++++ WebDriverAgentLib/Utilities/FBSocks5URI.m | 89 +++++++++++++ .../UnitTests/FBSocks5ConfigTests.m | 77 +++++++++++ .../UnitTests/FBSocks5URITests.m | 120 ++++++++++++++++++ 7 files changed, 512 insertions(+) create mode 100644 WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h create mode 100644 WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m create mode 100644 WebDriverAgentLib/Utilities/FBSocks5URI.h create mode 100644 WebDriverAgentLib/Utilities/FBSocks5URI.m create mode 100644 WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m create mode 100644 WebDriverAgentTests/UnitTests/FBSocks5URITests.m diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index df0ba76825..14aed87275 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -49,6 +49,16 @@ FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; FBCAFE000000000000005305 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000007002 /* FBSocks5TunnelProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */; }; + FBCAFE000000000000007003 /* FBSocks5TunnelProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */; }; + FBCAFE000000000000007004 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007005 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007012 /* FBSocks5URI.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007010 /* FBSocks5URI.h */; }; + FBCAFE000000000000007013 /* FBSocks5URI.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007010 /* FBSocks5URI.h */; }; + FBCAFE000000000000007014 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; + FBCAFE000000000000007015 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; + FBCAFE000000000000007021 /* FBSocks5URITests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007020 /* FBSocks5URITests.m */; }; + FBCAFE000000000000007023 /* FBSocks5ConfigTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */; }; FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; @@ -1060,6 +1070,12 @@ FBCAFE00000000000000510A /* FBExtLogging.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtLogging.h; sourceTree = ""; }; FBCAFE000000000000005300 /* FBBroadcastProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastProtocol.h; sourceTree = ""; }; FBCAFE000000000000005301 /* FBBroadcastProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastProtocol.m; sourceTree = ""; }; + FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5TunnelProtocol.h; sourceTree = ""; }; + FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5TunnelProtocol.m; sourceTree = ""; }; + FBCAFE000000000000007010 /* FBSocks5URI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5URI.h; sourceTree = ""; }; + FBCAFE000000000000007011 /* FBSocks5URI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URI.m; sourceTree = ""; }; + FBCAFE000000000000007020 /* FBSocks5URITests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URITests.m; sourceTree = ""; }; + FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5ConfigTests.m; sourceTree = ""; }; FBCAFE000000000000005310 /* FBBroadcastControlServer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastControlServer.h; sourceTree = ""; }; FBCAFE000000000000005311 /* FBBroadcastControlServer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastControlServer.m; sourceTree = ""; }; FBCAFE000000000000005320 /* FBBroadcastManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastManager.h; sourceTree = ""; }; @@ -2186,6 +2202,10 @@ FBCAFE000000000000000016 /* FBVideoStreamManager.m */, FBCAFE000000000000005300 /* FBBroadcastProtocol.h */, FBCAFE000000000000005301 /* FBBroadcastProtocol.m */, + FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */, + FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */, + FBCAFE000000000000007010 /* FBSocks5URI.h */, + FBCAFE000000000000007011 /* FBSocks5URI.m */, FBCAFE000000000000005310 /* FBBroadcastControlServer.h */, FBCAFE000000000000005311 /* FBBroadcastControlServer.m */, FBCAFE000000000000005320 /* FBBroadcastManager.h */, @@ -2281,6 +2301,8 @@ 715D554A2229891B00524509 /* FBExceptionHandlerTests.m */, 713352FC26CEF31D00523CBC /* FBLRUCacheTests.m */, EE18883C1DA663EB00307AA8 /* FBMathUtilsTests.m */, + FBCAFE000000000000007020 /* FBSocks5URITests.m */, + FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */, 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */, EE9B76571CF7987300275851 /* FBRouteTests.m */, FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */, @@ -2770,6 +2792,8 @@ FBCAFE00000000000000000F /* FBVideoEncoder.h in Headers */, FBCAFE000000000000000015 /* FBVideoStreamManager.h in Headers */, FBCAFE000000000000005302 /* FBBroadcastProtocol.h in Headers */, + FBCAFE000000000000007002 /* FBSocks5TunnelProtocol.h in Headers */, + FBCAFE000000000000007012 /* FBSocks5URI.h in Headers */, FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */, FBCAFE000000000000005322 /* FBBroadcastManager.h in Headers */, FBCAFE000000000000005332 /* FBBroadcastPickerHost.h in Headers */, @@ -3032,6 +3056,8 @@ FBCAFE00000000000000000E /* FBVideoEncoder.h in Headers */, FBCAFE000000000000000014 /* FBVideoStreamManager.h in Headers */, FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */, + FBCAFE000000000000007003 /* FBSocks5TunnelProtocol.h in Headers */, + FBCAFE000000000000007013 /* FBSocks5URI.h in Headers */, FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */, FBCAFE000000000000005323 /* FBBroadcastManager.h in Headers */, FBCAFE000000000000005333 /* FBBroadcastPickerHost.h in Headers */, @@ -3544,6 +3570,8 @@ FBCAFE000000000000000012 /* FBVideoEncoder.m in Sources */, FBCAFE000000000000000018 /* FBVideoStreamManager.m in Sources */, FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */, + FBCAFE000000000000007004 /* FBSocks5TunnelProtocol.m in Sources */, + FBCAFE000000000000007014 /* FBSocks5URI.m in Sources */, FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */, FBCAFE000000000000005324 /* FBBroadcastManager.m in Sources */, FBCAFE000000000000005334 /* FBBroadcastPickerHost.m in Sources */, @@ -3701,6 +3729,8 @@ FBCAFE000000000000000011 /* FBVideoEncoder.m in Sources */, FBCAFE000000000000000017 /* FBVideoStreamManager.m in Sources */, FBCAFE000000000000005305 /* FBBroadcastProtocol.m in Sources */, + FBCAFE000000000000007005 /* FBSocks5TunnelProtocol.m in Sources */, + FBCAFE000000000000007015 /* FBSocks5URI.m in Sources */, FBCAFE000000000000005315 /* FBBroadcastControlServer.m in Sources */, FBCAFE000000000000005325 /* FBBroadcastManager.m in Sources */, FBCAFE000000000000005335 /* FBBroadcastPickerHost.m in Sources */, @@ -3786,6 +3816,8 @@ EE6A89261D0B19E60083E92B /* FBSessionTests.m in Sources */, 71A7EAFC1E229302001DA4F2 /* FBClassChainTests.m in Sources */, EE18883D1DA663EB00307AA8 /* FBMathUtilsTests.m in Sources */, + FBCAFE000000000000007021 /* FBSocks5URITests.m in Sources */, + FBCAFE000000000000007023 /* FBSocks5ConfigTests.m in Sources */, FBCAFE00000000000000001A /* FBPixelBufferConverterTests.m in Sources */, FBCAFE00000000000000001C /* FBVideoEncoderTests.m in Sources */, FBCAFE000000000000004002 /* FBVideoStreamSessionTests.m in Sources */, diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h new file mode 100644 index 0000000000..aa9a613f0e --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h @@ -0,0 +1,68 @@ +/** + * 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 + +/** + Contract shared between WebDriverAgent and the WebDriverAgentTunnel packet tunnel extension. + + This file is compiled into both WebDriverAgentLib and the WebDriverAgentTunnel extension + target, so it must only depend on Foundation. + + The host configures the tunnel by storing a dictionary keyed by the FBSocks5Key* constants + in NETunnelProviderProtocol.providerConfiguration; the extension turns that dictionary into + a hev-socks5-tunnel YAML config via FBSocks5HevConfigFromProviderConfiguration. Runtime + traffic counters are queried through sendProviderMessage with the FBSocks5MsgStats verb; + the extension answers with UTF-8 JSON keyed by the FBSocks5StatsKey* constants. + */ + +/** providerConfiguration keys */ +extern NSString *const FBSocks5KeyHost; +extern NSString *const FBSocks5KeyPort; +extern NSString *const FBSocks5KeyUser; +extern NSString *const FBSocks5KeyPass; +/** @YES when DNS must be resolved through the proxy (socks5h://). */ +extern NSString *const FBSocks5KeyRemoteDNS; + +/** sendProviderMessage verb (UTF-8 encoded) asking the extension for traffic counters. */ +extern NSString *const FBSocks5MsgStats; + +/** Keys of the JSON stats reply (also used in the /mobilerun/socks5/stats response). */ +extern NSString *const FBSocks5StatsKeyConnected; +extern NSString *const FBSocks5StatsKeyHost; +extern NSString *const FBSocks5StatsKeyPort; +extern NSString *const FBSocks5StatsKeyUser; +extern NSString *const FBSocks5StatsKeyRxBytes; +extern NSString *const FBSocks5StatsKeyTxBytes; +extern NSString *const FBSocks5StatsKeyRxPackets; +extern NSString *const FBSocks5StatsKeyTxPackets; + +/** Default SOCKS5 port when the URI does not specify one. */ +extern const NSUInteger FBSocks5DefaultPort; + +/** The utun interface address; must match between NEPacketTunnelNetworkSettings and the + hev config, so both come from these constants. */ +extern NSString *const FBSocks5TunnelIPv4Address; +extern NSString *const FBSocks5TunnelIPv4Netmask; +/** Synthetic resolver address hev's mapdns listens on (socks5h mode); used as the tunnel's + DNS server so queries are hijacked into hostname-preserving CONNECTs. */ +extern NSString *const FBSocks5TunnelMapDNSAddress; +/** Interface MTU shared by NEPacketTunnelNetworkSettings and the hev config. */ +extern const NSUInteger FBSocks5TunnelMTU; + +/** + Renders the hev-socks5-tunnel YAML config for a providerConfiguration dictionary. + + @param providerConfiguration dictionary keyed by the FBSocks5Key* constants + @return the YAML config string consumed by hev_socks5_tunnel_main_from_str + */ +NSString *FBSocks5HevConfigFromProviderConfiguration(NSDictionary *providerConfiguration); + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m new file mode 100644 index 0000000000..34774e5f58 --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m @@ -0,0 +1,78 @@ +/** + * 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 "FBSocks5TunnelProtocol.h" + +NSString *const FBSocks5KeyHost = @"host"; +NSString *const FBSocks5KeyPort = @"port"; +NSString *const FBSocks5KeyUser = @"user"; +NSString *const FBSocks5KeyPass = @"pass"; +NSString *const FBSocks5KeyRemoteDNS = @"remoteDNS"; + +NSString *const FBSocks5MsgStats = @"stats"; + +NSString *const FBSocks5StatsKeyConnected = @"connected"; +NSString *const FBSocks5StatsKeyHost = @"host"; +NSString *const FBSocks5StatsKeyPort = @"port"; +NSString *const FBSocks5StatsKeyUser = @"user"; +NSString *const FBSocks5StatsKeyRxBytes = @"rxBytes"; +NSString *const FBSocks5StatsKeyTxBytes = @"txBytes"; +NSString *const FBSocks5StatsKeyRxPackets = @"rxPackets"; +NSString *const FBSocks5StatsKeyTxPackets = @"txPackets"; + +const NSUInteger FBSocks5DefaultPort = 1080; + +NSString *const FBSocks5TunnelIPv4Address = @"198.18.0.1"; +NSString *const FBSocks5TunnelIPv4Netmask = @"255.255.255.0"; +NSString *const FBSocks5TunnelMapDNSAddress = @"198.18.0.2"; +const NSUInteger FBSocks5TunnelMTU = 8500; + +// YAML single-quoted scalar: the only escape is doubling embedded single quotes. +static NSString *FBSocks5YAMLQuote(NSString *value) +{ + return [NSString stringWithFormat:@"'%@'", [value stringByReplacingOccurrencesOfString:@"'" withString:@"''"]]; +} + +NSString *FBSocks5HevConfigFromProviderConfiguration(NSDictionary *providerConfiguration) +{ + NSString *host = providerConfiguration[FBSocks5KeyHost]; + NSUInteger port = [providerConfiguration[FBSocks5KeyPort] unsignedIntegerValue]; + NSString *user = providerConfiguration[FBSocks5KeyUser]; + NSString *pass = providerConfiguration[FBSocks5KeyPass]; + BOOL remoteDNS = [providerConfiguration[FBSocks5KeyRemoteDNS] boolValue]; + + NSMutableString *yaml = [NSMutableString string]; + [yaml appendString:@"tunnel:\n"]; + [yaml appendFormat:@" mtu: %lu\n", (unsigned long)FBSocks5TunnelMTU]; + [yaml appendFormat:@" ipv4: %@\n", FBSocks5TunnelIPv4Address]; + [yaml appendString:@"socks5:\n"]; + [yaml appendFormat:@" address: %@\n", FBSocks5YAMLQuote(host)]; + [yaml appendFormat:@" port: %lu\n", (unsigned long)port]; + [yaml appendString:@" udp: 'udp'\n"]; + if (user.length > 0) { + [yaml appendFormat:@" username: %@\n", FBSocks5YAMLQuote(user)]; + } + if (pass.length > 0) { + [yaml appendFormat:@" password: %@\n", FBSocks5YAMLQuote(pass)]; + } + if (remoteDNS) { + // mapdns answers DNS queries with synthetic IPs from the 100.64.0.0/10 pool and + // restores the original hostname when those IPs are connected to, so the proxy + // receives CONNECT-by-hostname (socks5h semantics) without needing UDP support. + [yaml appendString:@"mapdns:\n"]; + [yaml appendFormat:@" address: %@\n", FBSocks5TunnelMapDNSAddress]; + [yaml appendString:@" port: 53\n"]; + [yaml appendString:@" network: 100.64.0.0\n"]; + [yaml appendString:@" netmask: 255.192.0.0\n"]; + [yaml appendString:@" cache-size: 10000\n"]; + } + [yaml appendString:@"misc:\n"]; + [yaml appendString:@" log-file: stderr\n"]; + [yaml appendString:@" log-level: warn\n"]; + return yaml.copy; +} diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.h b/WebDriverAgentLib/Utilities/FBSocks5URI.h new file mode 100644 index 0000000000..b88f716d7f --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.h @@ -0,0 +1,48 @@ +/** + * 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 + +/** + A parsed socks5:// or socks5h:// proxy URI as accepted by POST /mobilerun/socks5/connect. + + socks5h means hostnames are resolved remotely through the proxy (curl semantics); with + plain socks5 the device resolves them itself. Only depends on Foundation so it stays + unit-testable and compiles for tvOS. + */ +@interface FBSocks5URI : NSObject + +/** Proxy host: DNS name, IPv4, or IPv6 literal (without brackets). */ +@property (nonatomic, readonly, copy) NSString *host; +/** Proxy port; FBSocks5DefaultPort when the URI does not specify one. */ +@property (nonatomic, readonly) NSUInteger port; +/** Percent-decoded username, or nil when the URI carries no credentials. */ +@property (nonatomic, readonly, copy, nullable) NSString *user; +/** Percent-decoded password, or nil when the URI carries no credentials. */ +@property (nonatomic, readonly, copy, nullable) NSString *pass; +/** YES for socks5h:// (remote DNS resolution through the proxy). */ +@property (nonatomic, readonly) BOOL remoteDNS; + +/** + Parses and validates a SOCKS5 proxy URI. + + @param uriString the URI, e.g. socks5h://user:pass@proxy.example.com:1080 + @param error populated with a human-readable reason when parsing fails + @return the parsed URI, or nil when the string is not a valid socks5(h) URI + */ ++ (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **)error; + +/** The NETunnelProviderProtocol.providerConfiguration payload for this URI, + keyed by the FBSocks5Key* constants (credentials omitted when absent). */ +- (NSDictionary *)providerConfiguration; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.m b/WebDriverAgentLib/Utilities/FBSocks5URI.m new file mode 100644 index 0000000000..a622153a12 --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.m @@ -0,0 +1,89 @@ +/** + * 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 "FBSocks5URI.h" + +#import "FBSocks5TunnelProtocol.h" + +static NSString *const FBSocks5URIErrorDomain = @"com.facebook.WebDriverAgent.socks5"; + +static BOOL FBSocks5URISetError(NSError **error, NSString *message) +{ + if (nil != error) { + *error = [NSError errorWithDomain:FBSocks5URIErrorDomain + code:1 + userInfo:@{NSLocalizedDescriptionKey: message}]; + } + return NO; +} + +@interface FBSocks5URI () +@property (nonatomic, copy) NSString *host; +@property (nonatomic) NSUInteger port; +@property (nonatomic, copy, nullable) NSString *user; +@property (nonatomic, copy, nullable) NSString *pass; +@property (nonatomic) BOOL remoteDNS; +@end + +@implementation FBSocks5URI + ++ (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **)error +{ + if (0 == uriString.length) { + FBSocks5URISetError(error, @"The socks5 URI must not be empty"); + return nil; + } + NSURLComponents *components = [NSURLComponents componentsWithString:(NSString *)uriString]; + NSString *scheme = components.scheme.lowercaseString; + BOOL remoteDNS = [scheme isEqualToString:@"socks5h"]; + if (!remoteDNS && ![scheme isEqualToString:@"socks5"]) { + FBSocks5URISetError(error, [NSString stringWithFormat: + @"'%@' is not a valid SOCKS5 proxy URI. Expected socks5://[user:pass@]host[:port] or socks5h://… for remote DNS resolution", uriString]); + return nil; + } + NSString *host = components.host; + // Depending on the OS version, -[NSURLComponents host] may keep the brackets around + // IPv6 literals; the bare address is wanted everywhere downstream. + if (host.length >= 2 && [host hasPrefix:@"["] && [host hasSuffix:@"]"]) { + host = [host substringWithRange:NSMakeRange(1, host.length - 2)]; + } + if (0 == host.length) { + FBSocks5URISetError(error, [NSString stringWithFormat:@"The socks5 URI '%@' must include a proxy host", uriString]); + return nil; + } + NSUInteger port = FBSocks5DefaultPort; + if (nil != components.port) { + NSInteger rawPort = components.port.integerValue; + if (rawPort <= 0 || rawPort > UINT16_MAX) { + FBSocks5URISetError(error, [NSString stringWithFormat:@"The socks5 proxy port %@ is out of range (1-65535)", components.port]); + return nil; + } + port = (NSUInteger)rawPort; + } + + FBSocks5URI *uri = [[self alloc] init]; + uri.host = host; + uri.port = port; + uri.user = components.user.length > 0 ? components.user : nil; + uri.pass = components.password.length > 0 ? components.password : nil; + uri.remoteDNS = remoteDNS; + return uri; +} + +- (NSDictionary *)providerConfiguration +{ + NSMutableDictionary *config = [NSMutableDictionary dictionary]; + config[FBSocks5KeyHost] = self.host; + config[FBSocks5KeyPort] = @(self.port); + config[FBSocks5KeyRemoteDNS] = @(self.remoteDNS); + config[FBSocks5KeyUser] = self.user; + config[FBSocks5KeyPass] = self.pass; + return config.copy; +} + +@end diff --git a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m new file mode 100644 index 0000000000..116f4e8354 --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m @@ -0,0 +1,77 @@ +/** + * 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 + +#import "FBSocks5TunnelProtocol.h" +#import "FBSocks5URI.h" + +@interface FBSocks5ConfigTests : XCTestCase +@end + +@implementation FBSocks5ConfigTests + +- (NSString *)yamlForURI:(NSString *)uriString +{ + FBSocks5URI *uri = [FBSocks5URI parse:uriString error:nil]; + NSDictionary *config = uri.providerConfiguration; + return FBSocks5HevConfigFromProviderConfiguration(config); +} + +- (void)testYAMLContainsSocks5Server +{ + NSString *yaml = [self yamlForURI:@"socks5://1.2.3.4:9050"]; + XCTAssertTrue([yaml containsString:@"address: '1.2.3.4'"]); + XCTAssertTrue([yaml containsString:@"port: 9050"]); + XCTAssertTrue([yaml containsString:@"udp: 'udp'"]); +} + +- (void)testYAMLConfiguresTunnelInterfaceFromSharedConstants +{ + NSString *yaml = [self yamlForURI:@"socks5://1.2.3.4"]; + NSString *mtuLine = [NSString stringWithFormat:@"mtu: %lu", (unsigned long)FBSocks5TunnelMTU]; + NSString *ipv4Line = [NSString stringWithFormat:@"ipv4: %@", FBSocks5TunnelIPv4Address]; + XCTAssertTrue([yaml containsString:mtuLine]); + XCTAssertTrue([yaml containsString:ipv4Line]); +} + +- (void)testYAMLOmitsCredentialsWhenAbsent +{ + NSString *yaml = [self yamlForURI:@"socks5://1.2.3.4"]; + XCTAssertFalse([yaml containsString:@"username"]); + XCTAssertFalse([yaml containsString:@"password"]); +} + +- (void)testYAMLContainsCredentialsWhenProvided +{ + NSString *yaml = [self yamlForURI:@"socks5://user:pa55@1.2.3.4"]; + XCTAssertTrue([yaml containsString:@"username: 'user'"]); + XCTAssertTrue([yaml containsString:@"password: 'pa55'"]); +} + +- (void)testYAMLEscapesSingleQuotesInCredentials +{ + NSString *yaml = [self yamlForURI:@"socks5://user:p%27s@1.2.3.4"]; + XCTAssertTrue([yaml containsString:@"password: 'p''s'"]); +} + +- (void)testYAMLOmitsMapDNSForLocalResolution +{ + NSString *yaml = [self yamlForURI:@"socks5://1.2.3.4"]; + XCTAssertFalse([yaml containsString:@"mapdns"]); +} + +- (void)testYAMLEnablesMapDNSForRemoteResolution +{ + NSString *yaml = [self yamlForURI:@"socks5h://1.2.3.4"]; + NSString *dnsLine = [NSString stringWithFormat:@"address: %@", FBSocks5TunnelMapDNSAddress]; + XCTAssertTrue([yaml containsString:@"mapdns:"]); + XCTAssertTrue([yaml containsString:dnsLine]); +} + +@end diff --git a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m new file mode 100644 index 0000000000..2b14a45aa0 --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m @@ -0,0 +1,120 @@ +/** + * 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 + +#import "FBSocks5TunnelProtocol.h" +#import "FBSocks5URI.h" + +@interface FBSocks5URITests : XCTestCase +@end + +@implementation FBSocks5URITests + +- (void)testParsesPlainSocks5URI +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://1.2.3.4:9050" error:&error]; + XCTAssertNotNil(uri); + XCTAssertNil(error); + XCTAssertEqualObjects(uri.host, @"1.2.3.4"); + XCTAssertEqual(uri.port, 9050); + XCTAssertNil(uri.user); + XCTAssertNil(uri.pass); + XCTAssertFalse(uri.remoteDNS); +} + +- (void)testSocks5hSetsRemoteDNSAndDefaultsPort +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5h://proxy.example.com" error:&error]; + XCTAssertNotNil(uri); + XCTAssertEqualObjects(uri.host, @"proxy.example.com"); + XCTAssertEqual(uri.port, 1080); + XCTAssertTrue(uri.remoteDNS); +} + +- (void)testSchemeIsCaseInsensitive +{ + FBSocks5URI *uri = [FBSocks5URI parse:@"SOCKS5H://proxy.example.com" error:nil]; + XCTAssertNotNil(uri); + XCTAssertTrue(uri.remoteDNS); +} + +- (void)testParsesPercentEncodedCredentials +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://u%40ser:p%3As%27s@host.example:1081" error:&error]; + XCTAssertNotNil(uri); + XCTAssertEqualObjects(uri.user, @"u@ser"); + XCTAssertEqualObjects(uri.pass, @"p:s's"); + XCTAssertEqualObjects(uri.host, @"host.example"); + XCTAssertEqual(uri.port, 1081); +} + +- (void)testParsesIPv6LiteralHost +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://[fc00::1]:1080" error:&error]; + XCTAssertNotNil(uri); + XCTAssertEqualObjects(uri.host, @"fc00::1"); + XCTAssertEqual(uri.port, 1080); +} + +- (void)testRejectsUnsupportedScheme +{ + for (NSString *bad in @[@"http://host", @"socks4://host", @"socks://host"]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"%@ should be rejected", bad); + XCTAssertNotNil(error, @"%@ should produce an error", bad); + } +} + +- (void)testRejectsMissingHost +{ + for (NSString *bad in @[@"socks5://", @"socks5://:1080", @"socks5h://user:pass@"]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"%@ should be rejected", bad); + XCTAssertNotNil(error, @"%@ should produce an error", bad); + } +} + +- (void)testRejectsInvalidInput +{ + for (NSString *bad in @[@"", @"not a uri at all", @"socks5://h:port", @"socks5://h:70000"]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); + XCTAssertNotNil(error, @"'%@' should produce an error", bad); + } + NSError *error; + XCTAssertNil([FBSocks5URI parse:nil error:&error]); + XCTAssertNotNil(error); +} + +- (void)testProviderConfigurationContainsConnectionDetails +{ + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5h://user:pass@1.2.3.4:9050" error:nil]; + NSDictionary *config = uri.providerConfiguration; + XCTAssertEqualObjects(config[FBSocks5KeyHost], @"1.2.3.4"); + XCTAssertEqualObjects(config[FBSocks5KeyPort], @9050); + XCTAssertEqualObjects(config[FBSocks5KeyUser], @"user"); + XCTAssertEqualObjects(config[FBSocks5KeyPass], @"pass"); + XCTAssertEqualObjects(config[FBSocks5KeyRemoteDNS], @YES); +} + +- (void)testProviderConfigurationOmitsAbsentCredentials +{ + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://1.2.3.4" error:nil]; + NSDictionary *config = uri.providerConfiguration; + XCTAssertNil(config[FBSocks5KeyUser]); + XCTAssertNil(config[FBSocks5KeyPass]); + XCTAssertEqualObjects(config[FBSocks5KeyPort], @1080); + XCTAssertEqualObjects(config[FBSocks5KeyRemoteDNS], @NO); +} + +@end From 50799e393e0664327337f9e0a4b7ef4adb343e48 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:00:51 +0200 Subject: [PATCH 03/23] feat(socks5): add tunnel manager and /mobilerun/socks5 command endpoints Co-Authored-By: Claude Fable 5 --- WebDriverAgent.xcodeproj/project.pbxproj | 28 ++ .../Commands/FBMobilerunSocks5Commands.h | 29 ++ .../Commands/FBMobilerunSocks5Commands.m | 111 +++++ .../Utilities/FBSocks5TunnelManager.h | 73 ++++ .../Utilities/FBSocks5TunnelManager.m | 386 ++++++++++++++++++ .../FBMobilerunSocks5IntegrationTests.m | 86 ++++ 6 files changed, 713 insertions(+) create mode 100644 WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h create mode 100644 WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m create mode 100644 WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h create mode 100644 WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m create mode 100644 WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index 14aed87275..c78b370dfa 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -59,6 +59,15 @@ FBCAFE000000000000007015 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; FBCAFE000000000000007021 /* FBSocks5URITests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007020 /* FBSocks5URITests.m */; }; FBCAFE000000000000007023 /* FBSocks5ConfigTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */; }; + FBCAFE000000000000007032 /* FBSocks5TunnelManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */; }; + FBCAFE000000000000007033 /* FBSocks5TunnelManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */; }; + FBCAFE000000000000007034 /* FBSocks5TunnelManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */; }; + FBCAFE000000000000007035 /* FBSocks5TunnelManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */; }; + FBCAFE000000000000007042 /* FBMobilerunSocks5Commands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */; }; + FBCAFE000000000000007043 /* FBMobilerunSocks5Commands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */; }; + FBCAFE000000000000007044 /* FBMobilerunSocks5Commands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */; }; + FBCAFE000000000000007045 /* FBMobilerunSocks5Commands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */; }; + FBCAFE000000000000007331 /* FBMobilerunSocks5IntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */; }; FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; @@ -1076,6 +1085,11 @@ FBCAFE000000000000007011 /* FBSocks5URI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URI.m; sourceTree = ""; }; FBCAFE000000000000007020 /* FBSocks5URITests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URITests.m; sourceTree = ""; }; FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5ConfigTests.m; sourceTree = ""; }; + FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5TunnelManager.h; sourceTree = ""; }; + FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5TunnelManager.m; sourceTree = ""; }; + FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunSocks5Commands.h; sourceTree = ""; }; + FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunSocks5Commands.m; sourceTree = ""; }; + FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunSocks5IntegrationTests.m; sourceTree = ""; }; FBCAFE000000000000005310 /* FBBroadcastControlServer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastControlServer.h; sourceTree = ""; }; FBCAFE000000000000005311 /* FBBroadcastControlServer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastControlServer.m; sourceTree = ""; }; FBCAFE000000000000005320 /* FBBroadcastManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastManager.h; sourceTree = ""; }; @@ -2044,6 +2058,8 @@ FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */, FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */, FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */, + FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */, + FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */, ); name = Commands; path = WebDriverAgentLib/Commands; @@ -2206,6 +2222,8 @@ FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */, FBCAFE000000000000007010 /* FBSocks5URI.h */, FBCAFE000000000000007011 /* FBSocks5URI.m */, + FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */, + FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */, FBCAFE000000000000005310 /* FBBroadcastControlServer.h */, FBCAFE000000000000005311 /* FBBroadcastControlServer.m */, FBCAFE000000000000005320 /* FBBroadcastManager.h */, @@ -2271,6 +2289,7 @@ A1B2C3D41F001A00A1B0003 /* FBVoiceOverTests.m */, 71241D7D1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m */, FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */, + FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */, 71241D7F1FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m */, 7136C0F8243A182400921C76 /* FBW3CTypeActionsTests.m */, EEBBDB9A1D1032F0000738CD /* XCElementSnapshotHelperTests.m */, @@ -2794,6 +2813,8 @@ FBCAFE000000000000005302 /* FBBroadcastProtocol.h in Headers */, FBCAFE000000000000007002 /* FBSocks5TunnelProtocol.h in Headers */, FBCAFE000000000000007012 /* FBSocks5URI.h in Headers */, + FBCAFE000000000000007032 /* FBSocks5TunnelManager.h in Headers */, + FBCAFE000000000000007042 /* FBMobilerunSocks5Commands.h in Headers */, FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */, FBCAFE000000000000005322 /* FBBroadcastManager.h in Headers */, FBCAFE000000000000005332 /* FBBroadcastPickerHost.h in Headers */, @@ -3058,6 +3079,8 @@ FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */, FBCAFE000000000000007003 /* FBSocks5TunnelProtocol.h in Headers */, FBCAFE000000000000007013 /* FBSocks5URI.h in Headers */, + FBCAFE000000000000007033 /* FBSocks5TunnelManager.h in Headers */, + FBCAFE000000000000007043 /* FBMobilerunSocks5Commands.h in Headers */, FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */, FBCAFE000000000000005323 /* FBBroadcastManager.h in Headers */, FBCAFE000000000000005333 /* FBBroadcastPickerHost.h in Headers */, @@ -3572,6 +3595,8 @@ FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */, FBCAFE000000000000007004 /* FBSocks5TunnelProtocol.m in Sources */, FBCAFE000000000000007014 /* FBSocks5URI.m in Sources */, + FBCAFE000000000000007034 /* FBSocks5TunnelManager.m in Sources */, + FBCAFE000000000000007044 /* FBMobilerunSocks5Commands.m in Sources */, FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */, FBCAFE000000000000005324 /* FBBroadcastManager.m in Sources */, FBCAFE000000000000005334 /* FBBroadcastPickerHost.m in Sources */, @@ -3731,6 +3756,8 @@ FBCAFE000000000000005305 /* FBBroadcastProtocol.m in Sources */, FBCAFE000000000000007005 /* FBSocks5TunnelProtocol.m in Sources */, FBCAFE000000000000007015 /* FBSocks5URI.m in Sources */, + FBCAFE000000000000007035 /* FBSocks5TunnelManager.m in Sources */, + FBCAFE000000000000007045 /* FBMobilerunSocks5Commands.m in Sources */, FBCAFE000000000000005315 /* FBBroadcastControlServer.m in Sources */, FBCAFE000000000000005325 /* FBBroadcastManager.m in Sources */, FBCAFE000000000000005335 /* FBBroadcastPickerHost.m in Sources */, @@ -3751,6 +3778,7 @@ A1B2C3D41F001A00A1B0008 /* FBVoiceOverTests.m in Sources */, 71241D7E1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m in Sources */, FBCAFE000000000000003202 /* FBMobilerunActionsIntegrationTests.m in Sources */, + FBCAFE000000000000007331 /* FBMobilerunSocks5IntegrationTests.m in Sources */, 63FD950221F9D06100A3E356 /* FBImageProcessorTests.m in Sources */, 719CD8FF2126C90200C7D0C2 /* FBAutoAlertsHandlerTests.m in Sources */, EE2202131ECC612200A29571 /* FBIntegrationTestCase.m in Sources */, diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h new file mode 100644 index 0000000000..1bbba7b137 --- /dev/null +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h @@ -0,0 +1,29 @@ +/** + * 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 + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + mobilerun SOCKS5 VPN endpoints: routes device traffic through a SOCKS5 proxy via the + WebDriverAgentTunnel packet tunnel extension. + + POST /mobilerun/socks5/connect body: {"uri": "socks5[h]://[user:pass@]host[:port]", + "timeout": seconds (optional, default 30), + "consentButtonLabels": [string] (optional)} + POST /mobilerun/socks5/disconnect + GET /mobilerun/socks5/stats + */ +@interface FBMobilerunSocks5Commands : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m new file mode 100644 index 0000000000..5e13454161 --- /dev/null +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m @@ -0,0 +1,111 @@ +/** + * 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 "FBMobilerunSocks5Commands.h" + +#import "FBCommandStatus.h" +#import "FBResponsePayload.h" +#import "FBRoute.h" +#import "FBRouteRequest.h" +#import "FBSocks5TunnelManager.h" +#import "FBSocks5URI.h" + +static const NSTimeInterval FBSocks5ConnectDefaultTimeout = 30.0; + +@implementation FBMobilerunSocks5Commands + +#pragma mark - + ++ (NSArray *)routes +{ + return + @[ + [[FBRoute POST:@"/mobilerun/socks5/connect"] respondWithTarget:self action:@selector(handleConnect:)], + [[FBRoute POST:@"/mobilerun/socks5/disconnect"] respondWithTarget:self action:@selector(handleDisconnect:)], + [[FBRoute GET:@"/mobilerun/socks5/stats"] respondWithTarget:self action:@selector(handleStats:)], + [[FBRoute POST:@"/mobilerun/socks5/connect"].withoutSession respondWithTarget:self action:@selector(handleConnect:)], + [[FBRoute POST:@"/mobilerun/socks5/disconnect"].withoutSession respondWithTarget:self action:@selector(handleDisconnect:)], + [[FBRoute GET:@"/mobilerun/socks5/stats"].withoutSession respondWithTarget:self action:@selector(handleStats:)], + ]; +} + +#pragma mark - Commands + ++ (id)handleConnect:(FBRouteRequest *)request +{ + id uriValue = request.arguments[@"uri"]; + if (![uriValue isKindOfClass:NSString.class] || 0 == [uriValue length]) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"The request body must contain a 'uri' string like socks5h://user:pass@host:1080" + traceback:nil]); + } + NSError *parseError; + FBSocks5URI *uri = [FBSocks5URI parse:(NSString *)uriValue error:&parseError]; + if (nil == uri) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:parseError.localizedDescription + traceback:nil]); + } + NSTimeInterval timeout = FBSocks5ConnectDefaultTimeout; + id timeoutValue = request.arguments[@"timeout"]; + if ([timeoutValue isKindOfClass:NSNumber.class] && [timeoutValue doubleValue] > 0) { + timeout = [timeoutValue doubleValue]; + } + NSArray *consentLabels = nil; + id labelsValue = request.arguments[@"consentButtonLabels"]; + if ([labelsValue isKindOfClass:NSArray.class]) { + NSPredicate *isString = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary *bindings) { + return [item isKindOfClass:NSString.class]; + }]; + consentLabels = [(NSArray *)labelsValue filteredArrayUsingPredicate:isString]; + } + + NSError *error; + if (![FBSocks5TunnelManager.sharedInstance connectWithURI:uri + timeout:timeout + consentButtonLabels:consentLabels + error:&error]) { + return [self responseWithTunnelManagerError:error]; + } + return FBResponseWithObject(FBSocks5TunnelManager.sharedInstance.statsDictionary); +} + ++ (id)handleDisconnect:(FBRouteRequest *)request +{ + NSError *error; + if (![FBSocks5TunnelManager.sharedInstance disconnectWithError:&error]) { + return [self responseWithTunnelManagerError:error]; + } + return FBResponseWithObject(FBSocks5TunnelManager.sharedInstance.statsDictionary); +} + ++ (id)handleStats:(FBRouteRequest *)request +{ + return FBResponseWithObject(FBSocks5TunnelManager.sharedInstance.statsDictionary); +} + +#pragma mark - Helpers + ++ (id)responseWithTunnelManagerError:(NSError *)error +{ + if (![error.domain isEqualToString:FBSocks5TunnelManagerErrorDomain]) { + return FBResponseWithUnknownError(error); + } + switch ((FBSocks5TunnelManagerError)error.code) { + case FBSocks5TunnelManagerErrorUnsupported: + case FBSocks5TunnelManagerErrorNotAuthorized: + return FBResponseWithStatus([FBCommandStatus unsupportedOperationErrorWithMessage:error.localizedDescription + traceback:nil]); + case FBSocks5TunnelManagerErrorTimeout: + return FBResponseWithStatus([FBCommandStatus timeoutErrorWithMessage:error.localizedDescription + traceback:nil]); + case FBSocks5TunnelManagerErrorInternal: + return FBResponseWithUnknownError(error); + } + return FBResponseWithUnknownError(error); +} + +@end diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h new file mode 100644 index 0000000000..eade53b6cc --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h @@ -0,0 +1,73 @@ +/** + * 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 + +@class FBSocks5URI; + +NS_ASSUME_NONNULL_BEGIN + +extern NSErrorDomain const FBSocks5TunnelManagerErrorDomain; + +typedef NS_ERROR_ENUM(FBSocks5TunnelManagerErrorDomain, FBSocks5TunnelManagerError) { + /** Packet tunnels are not available in this environment (Simulator/tvOS). */ + FBSocks5TunnelManagerErrorUnsupported = 1, + /** Saving the VPN configuration was denied (consent alert rejected or not confirmable). */ + FBSocks5TunnelManagerErrorNotAuthorized = 2, + /** The tunnel did not reach the connected state within the allotted time. */ + FBSocks5TunnelManagerErrorTimeout = 3, + /** NetworkExtension reported an unexpected failure. */ + FBSocks5TunnelManagerErrorInternal = 4, +}; + +/** + Owns the NETunnelProviderManager lifecycle for the WebDriverAgentTunnel packet tunnel + extension embedded in the runner app: installs/updates the VPN configuration (auto-accepting + the system consent alert via UI automation), starts/stops the tunnel, and exposes traffic + counters queried from the extension. + + Must be called on the main thread (run-loop spinning + XCUITest interaction, like + FBBroadcastManager). + */ +@interface FBSocks5TunnelManager : NSObject + ++ (instancetype)sharedInstance; + +/** + Installs/updates the VPN configuration for the given proxy and starts the tunnel. + An already-running tunnel is replaced. Returns once the tunnel reports connected. + + @param uri the parsed socks5/socks5h proxy URI + @param timeout overall time budget in seconds for the tunnel to reach the connected state + @param consentButtonLabels labels to look for on the system "Add VPN Configurations" alert + (defaults to "Allow"); pass others when the device language is not English + @param error If there is an error, upon return contains an NSError describing the problem + @return NO in case of a failure + */ +- (BOOL)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error; + +/** + Stops the running tunnel (the VPN configuration stays installed). Succeeds when no tunnel + is running. + + @param error If there is an error, upon return contains an NSError describing the problem + @return NO in case of a failure + */ +- (BOOL)disconnectWithError:(NSError **)error; + +/** @return The stats payload for GET /mobilerun/socks5/stats: connected flag, proxy + host/port/user (omitted while disconnected) and rx/tx byte/packet counters. Never fails; + counters fall back to zero when the extension cannot be queried. */ +- (NSDictionary *)statsDictionary; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m new file mode 100644 index 0000000000..6691af9107 --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -0,0 +1,386 @@ +/** + * 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 "FBSocks5TunnelManager.h" + +#import "FBSocks5TunnelProtocol.h" +#import "FBSocks5URI.h" + +NSErrorDomain const FBSocks5TunnelManagerErrorDomain = @"com.facebook.WebDriverAgent.FBSocks5TunnelManager"; + +static BOOL FBSocks5Fail(NSError **error, FBSocks5TunnelManagerError code, NSString *message) +{ + if (nil != error) { + *error = [NSError errorWithDomain:FBSocks5TunnelManagerErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey: message}]; + } + return NO; +} + +static NSMutableDictionary *FBSocks5DisconnectedStats(void) +{ + return [@{ + FBSocks5StatsKeyConnected: @NO, + FBSocks5StatsKeyRxBytes: @0, + FBSocks5StatsKeyTxBytes: @0, + FBSocks5StatsKeyRxPackets: @0, + FBSocks5StatsKeyTxPackets: @0, + } mutableCopy]; +} + +#if TARGET_OS_SIMULATOR || TARGET_OS_TV + +@implementation FBSocks5TunnelManager + ++ (instancetype)sharedInstance +{ + static FBSocks5TunnelManager *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[FBSocks5TunnelManager alloc] init]; + }); + return instance; +} + +- (BOOL)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error +{ + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, + @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); +} + +- (BOOL)disconnectWithError:(NSError **)error +{ + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, + @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); +} + +- (NSDictionary *)statsDictionary +{ + return FBSocks5DisconnectedStats().copy; +} + +@end + +#else + +#import + +#import "FBLogger.h" +#import "FBRunLoopSpinner.h" +#import "FBScreen.h" +#import "XCUIApplication.h" +#import "XCUIApplication+FBHelpers.h" +#import "XCUIApplication+FBTouchAction.h" +#import "XCUIElement.h" + +// The embed script rewrites the extension bundle id to '.tunnel' +// (see Scripts/embed-tunnel-extension.sh), so derive it the same way at runtime. +static NSString *const FBSocks5TunnelBundleSuffix = @".tunnel"; +static NSString *const FBSocks5TunnelDescription = @"mobilerun SOCKS5"; +static const NSTimeInterval FBSocks5PreferencesTimeout = 10.0; +static const NSTimeInterval FBSocks5StopTimeout = 10.0; +static const NSTimeInterval FBSocks5StatsReplyTimeout = 3.0; +static const NSTimeInterval FBSocks5DefaultConnectTimeout = 30.0; + +@interface FBSocks5TunnelManager () +@property (nonatomic, nullable) NETunnelProviderManager *activeManager; +@end + +@implementation FBSocks5TunnelManager + ++ (instancetype)sharedInstance +{ + static FBSocks5TunnelManager *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[FBSocks5TunnelManager alloc] init]; + }); + return instance; +} + +#pragma mark - Helpers + +- (NSString *)providerBundleIdentifier +{ + return [NSBundle.mainBundle.bundleIdentifier stringByAppendingString:FBSocks5TunnelBundleSuffix]; +} + +- (BOOL)loadAllManagers:(NSArray **)outManagers error:(NSError **)error +{ + __block NSArray *managers = nil; + __block NSError *loadError = nil; + __block BOOL done = NO; + [NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray *all, NSError *err) { + managers = all; + loadError = err; + done = YES; + }]; + [[[[FBRunLoopSpinner new] timeout:FBSocks5PreferencesTimeout] interval:0.05] spinUntilTrue:^BOOL{ + return done; + }]; + if (!done || nil != loadError) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, + [NSString stringWithFormat:@"Cannot load the VPN preferences: %@", + loadError.localizedDescription ?: @"timed out"]); + } + if (nil != outManagers) { + *outManagers = managers ?: @[]; + } + return YES; +} + +- (nullable NETunnelProviderManager *)ownManagerIn:(NSArray *)managers +{ + NSString *providerId = self.providerBundleIdentifier; + for (NETunnelProviderManager *manager in managers) { + NETunnelProviderProtocol *protocol = (NETunnelProviderProtocol *)manager.protocolConfiguration; + if ([protocol isKindOfClass:NETunnelProviderProtocol.class] + && [protocol.providerBundleIdentifier isEqualToString:providerId]) { + return manager; + } + } + return nil; +} + +- (BOOL)waitUntilStopped:(NETunnelProviderManager *)manager +{ + return [[[[FBRunLoopSpinner new] timeout:FBSocks5StopTimeout] interval:0.2] spinUntilTrue:^BOOL{ + NEVPNStatus status = manager.connection.status; + return status == NEVPNStatusDisconnected || status == NEVPNStatusInvalid; + }]; +} + +// The first save of the configuration makes the system present a '"…" Would Like to Add VPN +// Configurations' alert that must be confirmed before the save completion fires, so this is +// polled from within the save wait loop. Devices with a passcode additionally ask for it, +// which cannot be automated (documented in docs/socks5-tunnel.md). +- (BOOL)tapConsentButtonWithLabels:(NSArray *)labels runner:(XCUIApplication *)runner +{ + XCUIApplication *system = XCUIApplication.fb_systemApplication; + for (NSString *label in labels) { + XCUIElement *button = system.buttons[label]; + if (!button.exists) { + continue; + } + CGRect frame = button.frame; + if (CGRectIsEmpty(frame)) { + continue; + } + // Tap via WDA's own event synthesis instead of XCUIElement.tap: a missed XCUIElement tap + // records an XCTest failure that tears down the test session (see FBBroadcastManager). + CGFloat scale = (CGFloat)[FBScreen scale]; + CGPoint center = CGPointMake(CGRectGetMidX(frame) * scale, CGRectGetMidY(frame) * scale); + NSArray *tapActions = @[ + @{@"type": @"pointerDown", @"x": @(center.x), @"y": @(center.y)}, + @{@"type": @"pause", @"duration": @60}, + @{@"type": @"pointerUp", @"x": @(center.x), @"y": @(center.y)}, + ]; + NSError *tapError; + if ([runner fb_performMobilerunActions:tapActions scale:scale error:&tapError]) { + [FBLogger logFmt:@"socks5/connect: tapped the VPN consent button '%@'", label]; + return YES; + } + [FBLogger logFmt:@"socks5/connect: cannot tap the VPN consent button '%@': %@", + label, tapError.localizedDescription]; + } + return NO; +} + +#pragma mark - Public API + +- (BOOL)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error +{ + NSTimeInterval budget = timeout > 0 ? timeout : FBSocks5DefaultConnectTimeout; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:budget]; + NSArray *labels = consentButtonLabels.count > 0 ? consentButtonLabels : @[@"Allow"]; + + NSArray *managers; + if (![self loadAllManagers:&managers error:error]) { + return NO; + } + NETunnelProviderManager *manager = [self ownManagerIn:managers] ?: [[NETunnelProviderManager alloc] init]; + + // Connecting while a tunnel runs replaces it. + NEVPNStatus status = manager.connection.status; + if (status == NEVPNStatusConnected || status == NEVPNStatusConnecting || status == NEVPNStatusReasserting) { + [FBLogger log:@"socks5/connect: stopping the already running tunnel first"]; + [manager.connection stopVPNTunnel]; + if (![self waitUntilStopped:manager]) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out stopping the previously running SOCKS5 tunnel"); + } + } + + NETunnelProviderProtocol *protocol = [[NETunnelProviderProtocol alloc] init]; + protocol.providerBundleIdentifier = self.providerBundleIdentifier; + protocol.serverAddress = uri.host; + protocol.providerConfiguration = uri.providerConfiguration; + protocol.disconnectOnSleep = NO; + manager.protocolConfiguration = protocol; + manager.localizedDescription = FBSocks5TunnelDescription; + manager.enabled = YES; + + __block BOOL saveDone = NO; + __block NSError *saveError = nil; + [manager saveToPreferencesWithCompletionHandler:^(NSError *err) { + saveError = err; + saveDone = YES; + }]; + XCUIApplication *runner = [[XCUIApplication alloc] initWithBundleIdentifier:(NSString *)NSBundle.mainBundle.bundleIdentifier]; + __block BOOL consentTapped = NO; + [[[[FBRunLoopSpinner new] timeout:MAX(deadline.timeIntervalSinceNow, 1.0)] interval:0.3] spinUntilTrue:^BOOL{ + if (saveDone) { + return YES; + } + if (!consentTapped) { + consentTapped = [self tapConsentButtonWithLabels:labels runner:runner]; + } + return NO; + }]; + if (!saveDone) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorNotAuthorized, + [NSString stringWithFormat: + @"Timed out saving the VPN configuration. The consent alert was %@; " + "pass 'consentButtonLabels' if the device language is not English, and note that " + "devices with a passcode cannot confirm the VPN consent automatically", + consentTapped ? @"confirmed" : @"not confirmed"]); + } + if (nil != saveError) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorNotAuthorized, + [NSString stringWithFormat:@"The VPN configuration was not authorized: %@", + saveError.localizedDescription]); + } + + // A freshly saved configuration must be re-loaded before the tunnel can be started. + __block BOOL reloadDone = NO; + __block NSError *reloadError = nil; + [manager loadFromPreferencesWithCompletionHandler:^(NSError *err) { + reloadError = err; + reloadDone = YES; + }]; + [[[[FBRunLoopSpinner new] timeout:FBSocks5PreferencesTimeout] interval:0.05] spinUntilTrue:^BOOL{ + return reloadDone; + }]; + if (!reloadDone || nil != reloadError) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, + [NSString stringWithFormat:@"Cannot reload the saved VPN configuration: %@", + reloadError.localizedDescription ?: @"timed out"]); + } + + NSError *startError; + if (![(NETunnelProviderSession *)manager.connection startVPNTunnelWithOptions:nil andReturnError:&startError]) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, + [NSString stringWithFormat:@"Cannot start the SOCKS5 tunnel: %@", + startError.localizedDescription]); + } + BOOL connected = [[[[FBRunLoopSpinner new] timeout:MAX(deadline.timeIntervalSinceNow, 1.0)] interval:0.2] spinUntilTrue:^BOOL{ + return manager.connection.status == NEVPNStatusConnected; + }]; + if (!connected) { + NEVPNStatus finalStatus = manager.connection.status; + [manager.connection stopVPNTunnel]; + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + [NSString stringWithFormat: + @"The SOCKS5 tunnel did not connect within %.0fs (status %ld). " + "Check that the proxy at %@:%lu is reachable from the device", + budget, (long)finalStatus, uri.host, (unsigned long)uri.port]); + } + self.activeManager = manager; + [FBLogger logFmt:@"socks5/connect: tunnel connected through %@:%lu", uri.host, (unsigned long)uri.port]; + return YES; +} + +- (BOOL)disconnectWithError:(NSError **)error +{ + NSArray *managers; + if (![self loadAllManagers:&managers error:error]) { + return NO; + } + NETunnelProviderManager *manager = [self ownManagerIn:managers] ?: self.activeManager; + if (nil == manager) { + return YES; + } + self.activeManager = manager; + NEVPNStatus status = manager.connection.status; + if (status == NEVPNStatusDisconnected || status == NEVPNStatusInvalid) { + return YES; + } + [manager.connection stopVPNTunnel]; + if (![self waitUntilStopped:manager]) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out stopping the SOCKS5 tunnel"); + } + [FBLogger log:@"socks5/disconnect: tunnel stopped"]; + return YES; +} + +- (NSDictionary *)statsDictionary +{ + NSMutableDictionary *stats = FBSocks5DisconnectedStats(); + NETunnelProviderManager *manager = self.activeManager; + if (nil == manager) { + // Adopt a tunnel that survived a WDA restart (the configuration persists per install). + NSArray *managers; + if ([self loadAllManagers:&managers error:nil]) { + manager = [self ownManagerIn:managers]; + self.activeManager = manager; + } + } + if (manager.connection.status != NEVPNStatusConnected) { + return stats.copy; + } + stats[FBSocks5StatsKeyConnected] = @YES; + NETunnelProviderProtocol *protocol = (NETunnelProviderProtocol *)manager.protocolConfiguration; + if ([protocol isKindOfClass:NETunnelProviderProtocol.class]) { + NSDictionary *config = protocol.providerConfiguration; + stats[FBSocks5StatsKeyHost] = config[FBSocks5KeyHost]; + stats[FBSocks5StatsKeyPort] = config[FBSocks5KeyPort]; + stats[FBSocks5StatsKeyUser] = config[FBSocks5KeyUser]; + } + + NETunnelProviderSession *session = (NETunnelProviderSession *)manager.connection; + __block NSDictionary *counters = nil; + __block BOOL done = NO; + NSError *messageError; + BOOL sent = [session sendProviderMessage:(NSData *)[FBSocks5MsgStats dataUsingEncoding:NSUTF8StringEncoding] + returnError:&messageError + responseHandler:^(NSData *responseData) { + if (nil != responseData) { + id parsed = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:nil]; + counters = [parsed isKindOfClass:NSDictionary.class] ? parsed : nil; + } + done = YES; + }]; + if (sent) { + [[[[FBRunLoopSpinner new] timeout:FBSocks5StatsReplyTimeout] interval:0.05] spinUntilTrue:^BOOL{ + return done; + }]; + } else { + [FBLogger logFmt:@"socks5/stats: cannot query the tunnel extension: %@", messageError.localizedDescription]; + } + // Counters stay zero when the extension cannot answer in time; stats polling must not fail. + for (NSString *key in @[FBSocks5StatsKeyRxBytes, FBSocks5StatsKeyTxBytes, + FBSocks5StatsKeyRxPackets, FBSocks5StatsKeyTxPackets]) { + NSNumber *value = counters[key]; + if ([value isKindOfClass:NSNumber.class]) { + stats[key] = value; + } + } + return stats.copy; +} + +@end + +#endif diff --git a/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m b/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m new file mode 100644 index 0000000000..af10303d67 --- /dev/null +++ b/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m @@ -0,0 +1,86 @@ +/** + * 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 + +#import "FBMobilerunSocks5Commands.h" +#import "FBRoute.h" +#import "FBSocks5TunnelManager.h" +#import "FBSocks5TunnelProtocol.h" +#import "FBSocks5URI.h" + +/** + NetworkExtension packet tunnels cannot run on the Simulator, so these tests pin down the + contract the endpoints must keep there: connect/disconnect surface the unsupported error and + stats always returns the well-formed disconnected payload. The actual tunnel path needs a + real device with paid-team signing (see docs/socks5-tunnel.md). + */ +@interface FBMobilerunSocks5IntegrationTests : XCTestCase +@end + +@implementation FBMobilerunSocks5IntegrationTests + +#if TARGET_OS_SIMULATOR + +- (void)testConnectIsUnsupportedOnSimulator +{ + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5h://user:pass@1.2.3.4:1080" error:nil]; + XCTAssertNotNil(uri); + NSError *error; + BOOL connected = [FBSocks5TunnelManager.sharedInstance connectWithURI:(FBSocks5URI *)uri + timeout:5 + consentButtonLabels:nil + error:&error]; + XCTAssertFalse(connected); + XCTAssertEqualObjects(error.domain, FBSocks5TunnelManagerErrorDomain); + XCTAssertEqual(error.code, FBSocks5TunnelManagerErrorUnsupported); +} + +- (void)testDisconnectIsUnsupportedOnSimulator +{ + NSError *error; + XCTAssertFalse([FBSocks5TunnelManager.sharedInstance disconnectWithError:&error]); + XCTAssertEqualObjects(error.domain, FBSocks5TunnelManagerErrorDomain); + XCTAssertEqual(error.code, FBSocks5TunnelManagerErrorUnsupported); +} + +#endif + +- (void)testStatsReportDisconnectedShape +{ + NSDictionary *stats = [FBSocks5TunnelManager.sharedInstance statsDictionary]; + XCTAssertEqualObjects(stats[FBSocks5StatsKeyConnected], @NO); + for (NSString *key in @[FBSocks5StatsKeyRxBytes, FBSocks5StatsKeyTxBytes, + FBSocks5StatsKeyRxPackets, FBSocks5StatsKeyTxPackets]) { + XCTAssertEqualObjects(stats[key], @0, @"missing zero counter for %@", key); + } + XCTAssertNil(stats[FBSocks5StatsKeyHost]); + XCTAssertNil(stats[FBSocks5StatsKeyPort]); + XCTAssertNil(stats[FBSocks5StatsKeyUser]); +} + +- (void)testRoutesAreRegistered +{ + NSArray *routes = [FBMobilerunSocks5Commands routes]; + XCTAssertEqual(routes.count, 6); + NSMutableSet *seen = [NSMutableSet set]; + for (FBRoute *route in routes) { + if ([route.path hasSuffix:@"/mobilerun/socks5/connect"] || [route.path hasSuffix:@"/mobilerun/socks5/disconnect"]) { + XCTAssertEqualObjects(route.verb, @"POST"); + } else if ([route.path hasSuffix:@"/mobilerun/socks5/stats"]) { + XCTAssertEqualObjects(route.verb, @"GET"); + } else { + XCTFail(@"unexpected route %@ %@", route.verb, route.path); + } + [seen addObject:route.path]; + } + // Each endpoint must be reachable both with and without a session prefix. + XCTAssertEqual(seen.count, 6); +} + +@end From 2c7abcceada92561c575d2d7a0dd0d14b6a40eea Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:08:48 +0200 Subject: [PATCH 04/23] feat(socks5): add WebDriverAgentTunnel packet tunnel extension target Co-Authored-By: Claude Fable 5 --- WebDriverAgent.xcodeproj/project.pbxproj | 140 ++++++++++++++ .../WebDriverAgentRunner.entitlements | 10 + WebDriverAgentTunnel/FBTunFdFinder.h | 23 +++ WebDriverAgentTunnel/FBTunFdFinder.m | 58 ++++++ WebDriverAgentTunnel/FBTunnelHevRunner.h | 49 +++++ WebDriverAgentTunnel/FBTunnelHevRunner.m | 61 ++++++ WebDriverAgentTunnel/FBTunnelPacketProvider.h | 22 +++ WebDriverAgentTunnel/FBTunnelPacketProvider.m | 179 ++++++++++++++++++ WebDriverAgentTunnel/Info.plist | 33 ++++ .../WebDriverAgentTunnel.entitlements | 10 + 10 files changed, 585 insertions(+) create mode 100644 WebDriverAgentRunner/WebDriverAgentRunner.entitlements create mode 100644 WebDriverAgentTunnel/FBTunFdFinder.h create mode 100644 WebDriverAgentTunnel/FBTunFdFinder.m create mode 100644 WebDriverAgentTunnel/FBTunnelHevRunner.h create mode 100644 WebDriverAgentTunnel/FBTunnelHevRunner.m create mode 100644 WebDriverAgentTunnel/FBTunnelPacketProvider.h create mode 100644 WebDriverAgentTunnel/FBTunnelPacketProvider.m create mode 100644 WebDriverAgentTunnel/Info.plist create mode 100644 WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index c78b370dfa..a25ed96130 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -45,6 +45,11 @@ FBCAFE000000000000005201 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; FBCAFE000000000000005202 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; FBCAFE000000000000005203 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000007006 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007112 /* FBTunnelPacketProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */; }; + FBCAFE000000000000007115 /* FBTunFdFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007114 /* FBTunFdFinder.m */; }; + FBCAFE000000000000007118 /* FBTunnelHevRunner.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007117 /* FBTunnelHevRunner.m */; }; + FBCAFE000000000000007121 /* HevSocks5Tunnel.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */; }; FBCAFE000000000000005302 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; @@ -1011,6 +1016,13 @@ remoteGlobalIDString = FBCAFE000000000000005001; remoteInfo = WebDriverAgentBroadcast; }; + FBCAFE000000000000007109 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 91F9DAE11B99DBC2001349B2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = FBCAFE000000000000007101; + remoteInfo = WebDriverAgentTunnel; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -1059,6 +1071,17 @@ FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSessionTests.m; sourceTree = ""; }; FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; }; FBCAFE00000000000000500C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FBCAFE000000000000007102 /* WebDriverAgentTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + FBCAFE00000000000000710C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = WebDriverAgentTunnel/Info.plist; sourceTree = SOURCE_ROOT; }; + FBCAFE000000000000007110 /* FBTunnelPacketProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunnelPacketProvider.h; sourceTree = ""; }; + FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunnelPacketProvider.m; sourceTree = ""; }; + FBCAFE000000000000007113 /* FBTunFdFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunFdFinder.h; sourceTree = ""; }; + FBCAFE000000000000007114 /* FBTunFdFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunFdFinder.m; sourceTree = ""; }; + FBCAFE000000000000007116 /* FBTunnelHevRunner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunnelHevRunner.h; sourceTree = ""; }; + FBCAFE000000000000007117 /* FBTunnelHevRunner.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunnelHevRunner.m; sourceTree = ""; }; + FBCAFE000000000000007119 /* WebDriverAgentTunnel.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = WebDriverAgentTunnel.entitlements; sourceTree = ""; }; + FBCAFE00000000000000711A /* WebDriverAgentRunner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = WebDriverAgentRunner.entitlements; path = WebDriverAgentRunner/WebDriverAgentRunner.entitlements; sourceTree = SOURCE_ROOT; }; + FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = HevSocks5Tunnel.xcframework; path = ThirdParty/HevSocks5Tunnel.xcframework; sourceTree = SOURCE_ROOT; }; FBCAFE000000000000005101 /* FBBroadcastSampleHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastSampleHandler.h; sourceTree = ""; }; FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastSampleHandler.m; sourceTree = ""; }; FBCAFE000000000000005104 /* FBExtBroadcastClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtBroadcastClient.h; sourceTree = ""; }; @@ -1687,6 +1710,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBCAFE000000000000007107 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FBCAFE000000000000007121 /* HevSocks5Tunnel.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1795,6 +1826,7 @@ EE9B75F91CF7964100275851 /* WebDriverAgentTests */, EEF988341C486655005CA669 /* WebDriverAgentRunner */, FBCAFE00000000000000500B /* WebDriverAgentBroadcast */, + FBCAFE00000000000000710B /* WebDriverAgentTunnel */, EE9AB8021CAEE182008C271F /* Scripts */, 91F9DAEA1B99DBC2001349B2 /* Products */, B6E83A410C45944B036B6B0F /* Frameworks */, @@ -1819,6 +1851,7 @@ 641EE6F82240C5CA00173FCB /* WebDriverAgentLib_tvOS.framework */, 64B264F9228C50E0002A5025 /* UnitTests_tvOS.xctest */, FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */, + FBCAFE000000000000007102 /* WebDriverAgentTunnel.appex */, ); name = Products; sourceTree = ""; @@ -2537,6 +2570,7 @@ children = ( EE9AB7FC1CAEE048008C271F /* Info.plist */, EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */, + FBCAFE00000000000000711A /* WebDriverAgentRunner.entitlements */, A1B2C3D4E5F600000000001A /* Assets.xcassets */, ); name = WebDriverAgentRunner; @@ -2560,6 +2594,22 @@ path = WebDriverAgentBroadcast; sourceTree = ""; }; + FBCAFE00000000000000710B /* WebDriverAgentTunnel */ = { + isa = PBXGroup; + children = ( + FBCAFE00000000000000710C /* Info.plist */, + FBCAFE000000000000007110 /* FBTunnelPacketProvider.h */, + FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */, + FBCAFE000000000000007113 /* FBTunFdFinder.h */, + FBCAFE000000000000007114 /* FBTunFdFinder.m */, + FBCAFE000000000000007116 /* FBTunnelHevRunner.h */, + FBCAFE000000000000007117 /* FBTunnelHevRunner.m */, + FBCAFE000000000000007119 /* WebDriverAgentTunnel.entitlements */, + FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */, + ); + path = WebDriverAgentTunnel; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -3274,6 +3324,7 @@ dependencies = ( EE158B5C1CBD462500A3E3F0 /* PBXTargetDependency */, FBCAFE00000000000000500A /* PBXTargetDependency */, + FBCAFE00000000000000710A /* PBXTargetDependency */, ); name = WebDriverAgentRunner; productName = XCTUITestRunner; @@ -3297,6 +3348,23 @@ productReference = FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */; productType = "com.apple.product-type.app-extension"; }; + FBCAFE000000000000007101 /* WebDriverAgentTunnel */ = { + isa = PBXNativeTarget; + buildConfigurationList = FBCAFE000000000000007103 /* Build configuration list for PBXNativeTarget "WebDriverAgentTunnel" */; + buildPhases = ( + FBCAFE000000000000007106 /* Sources */, + FBCAFE000000000000007107 /* Frameworks */, + FBCAFE000000000000007108 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WebDriverAgentTunnel; + productName = WebDriverAgentTunnel; + productReference = FBCAFE000000000000007102 /* WebDriverAgentTunnel.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -3336,6 +3404,9 @@ FBCAFE000000000000005001 = { CreatedOnToolsVersion = 15.0; }; + FBCAFE000000000000007101 = { + CreatedOnToolsVersion = 15.0; + }; }; }; buildConfigurationList = 91F9DAE41B99DBC2001349B2 /* Build configuration list for PBXProject "WebDriverAgent" */; @@ -3355,6 +3426,7 @@ 641EE5D52240C5CA00173FCB /* WebDriverAgentLib_tvOS */, EEF988291C486603005CA669 /* WebDriverAgentRunner */, FBCAFE000000000000005001 /* WebDriverAgentBroadcast */, + FBCAFE000000000000007101 /* WebDriverAgentTunnel */, 641EE2D92240BBE300173FCB /* WebDriverAgentRunner_tvOS */, EE836C011C0F118600D87246 /* UnitTests */, 64B264F8228C50E0002A5025 /* UnitTests_tvOS */, @@ -3446,6 +3518,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBCAFE000000000000007108 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -3913,6 +3992,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBCAFE000000000000007106 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FBCAFE000000000000007112 /* FBTunnelPacketProvider.m in Sources */, + FBCAFE000000000000007115 /* FBTunFdFinder.m in Sources */, + FBCAFE000000000000007118 /* FBTunnelHevRunner.m in Sources */, + FBCAFE000000000000007006 /* FBSocks5TunnelProtocol.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -3971,6 +4061,11 @@ target = FBCAFE000000000000005001 /* WebDriverAgentBroadcast */; targetProxy = FBCAFE000000000000005009 /* PBXContainerItemProxy */; }; + FBCAFE00000000000000710A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FBCAFE000000000000007101 /* WebDriverAgentTunnel */; + targetProxy = FBCAFE000000000000007109 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -4754,6 +4849,7 @@ baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "$(WDA_RUNNER_ENTITLEMENTS)"; CLANG_STATIC_ANALYZER_MODE = deep; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_TESTING_SEARCH_PATHS = YES; @@ -4808,6 +4904,7 @@ baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "$(WDA_RUNNER_ENTITLEMENTS)"; CLANG_STATIC_ANALYZER_MODE = deep; ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -4889,6 +4986,40 @@ }; name = Release; }; + FBCAFE000000000000007104 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = "$(WDA_TUNNEL_ENTITLEMENTS)"; + DEBUG_INFORMATION_FORMAT = dwarf; + GCC_TREAT_WARNINGS_AS_ERRORS = NO; + INFOPLIST_FILE = WebDriverAgentTunnel/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner.xctrunner.tunnel; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FBCAFE000000000000007105 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = "$(WDA_TUNNEL_ENTITLEMENTS)"; + GCC_TREAT_WARNINGS_AS_ERRORS = NO; + INFOPLIST_FILE = WebDriverAgentTunnel/Info.plist; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner.xctrunner.tunnel; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -5000,6 +5131,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FBCAFE000000000000007103 /* Build configuration list for PBXNativeTarget "WebDriverAgentTunnel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FBCAFE000000000000007104 /* Debug */, + FBCAFE000000000000007105 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 91F9DAE11B99DBC2001349B2 /* Project object */; diff --git a/WebDriverAgentRunner/WebDriverAgentRunner.entitlements b/WebDriverAgentRunner/WebDriverAgentRunner.entitlements new file mode 100644 index 0000000000..ffab33e018 --- /dev/null +++ b/WebDriverAgentRunner/WebDriverAgentRunner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + diff --git a/WebDriverAgentTunnel/FBTunFdFinder.h b/WebDriverAgentTunnel/FBTunFdFinder.h new file mode 100644 index 0000000000..835bdf1b94 --- /dev/null +++ b/WebDriverAgentTunnel/FBTunFdFinder.h @@ -0,0 +1,23 @@ +/** + * 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 + +/** + Locates the file descriptor of the packet tunnel's utun interface inside a + NEPacketTunnelProvider process. There is no supported API for this; the descriptor is found + by scanning the process' descriptors for the kernel-control socket backing NEPacketTunnelFlow + (public POSIX calls only). + + @return the utun file descriptor, or -1 when none is found + */ +int FBTunnelFindTunFd(void); + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentTunnel/FBTunFdFinder.m b/WebDriverAgentTunnel/FBTunFdFinder.m new file mode 100644 index 0000000000..72ff129e76 --- /dev/null +++ b/WebDriverAgentTunnel/FBTunFdFinder.m @@ -0,0 +1,58 @@ +/** + * 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. + * + * The utun descriptor scan is adapted from Tun2SocksKit (MIT License, + * Copyright (c) 2023 Ebrahim Tahernejad, + * https://github.com/EbrahimTahernejad/Tun2SocksKit). + */ + +#import "FBTunFdFinder.h" + +#include +#include +#include + +// From , which is not part of the iOS SDK. +#define FB_CTLIOCGINFO 0xc0644e03UL + +struct fb_ctl_info { + uint32_t ctl_id; + char ctl_name[96]; +}; + +struct fb_sockaddr_ctl { + unsigned char sc_len; + unsigned char sc_family; + uint16_t ss_sysaddr; + uint32_t sc_id; + uint32_t sc_unit; + uint32_t sc_reserved[5]; +}; + +int FBTunnelFindTunFd(void) +{ + struct fb_ctl_info ctlInfo; + memset(&ctlInfo, 0, sizeof(ctlInfo)); + strlcpy(ctlInfo.ctl_name, "com.apple.net.utun_control", sizeof(ctlInfo.ctl_name)); + for (int fd = 0; fd <= 1024; fd++) { + struct fb_sockaddr_ctl addr; + memset(&addr, 0, sizeof(addr)); + socklen_t len = sizeof(addr); + if (0 != getpeername(fd, (struct sockaddr *)&addr, &len) || AF_SYSTEM != addr.sc_family) { + continue; + } + if (0 == ctlInfo.ctl_id) { + if (0 != ioctl(fd, FB_CTLIOCGINFO, &ctlInfo)) { + continue; + } + } + if (addr.sc_id == ctlInfo.ctl_id) { + return fd; + } + } + return -1; +} diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.h b/WebDriverAgentTunnel/FBTunnelHevRunner.h new file mode 100644 index 0000000000..bd2608f27c --- /dev/null +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.h @@ -0,0 +1,49 @@ +/** + * 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 + +/** + Runs the hev-socks5-tunnel engine on a dedicated thread. + hev_socks5_tunnel_main_from_str blocks for the tunnel's whole lifetime, so start spawns a + thread and stop unblocks it via hev_socks5_tunnel_quit. + */ +@interface FBTunnelHevRunner : NSObject + +/** YES while the engine thread is running. */ +@property (atomic, readonly) BOOL isRunning; + +/** + Starts the engine thread. No-op while the engine is already running. + + @param configYAML the hev-socks5-tunnel YAML config + @param tunFd the utun file descriptor the engine reads/writes packets on + */ +- (void)startWithConfigYAML:(NSString *)configYAML tunFd:(int)tunFd; + +/** + Asks the engine to quit and waits for the engine thread to exit. + + @param timeout maximum time in seconds to wait for the engine thread + @return YES when the engine stopped within the timeout + */ +- (BOOL)stopAndWait:(NSTimeInterval)timeout; + +/** + Snapshots the engine's cumulative traffic counters (safe to call cross-thread while running). + */ +- (void)getStatsTxPackets:(size_t *)txPackets + txBytes:(size_t *)txBytes + rxPackets:(size_t *)rxPackets + rxBytes:(size_t *)rxBytes; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.m b/WebDriverAgentTunnel/FBTunnelHevRunner.m new file mode 100644 index 0000000000..73e4cffaf5 --- /dev/null +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.m @@ -0,0 +1,61 @@ +/** + * 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 "FBTunnelHevRunner.h" + +#import + +@interface FBTunnelHevRunner () +@property (atomic, readwrite) BOOL isRunning; +@property (nonatomic, nullable) dispatch_semaphore_t exitSemaphore; +@end + +@implementation FBTunnelHevRunner + +- (void)startWithConfigYAML:(NSString *)configYAML tunFd:(int)tunFd +{ + if (self.isRunning) { + return; + } + self.isRunning = YES; + dispatch_semaphore_t exitSemaphore = dispatch_semaphore_create(0); + self.exitSemaphore = exitSemaphore; + NSData *config = [configYAML dataUsingEncoding:NSUTF8StringEncoding]; + __weak typeof(self) weakSelf = self; + NSThread *thread = [[NSThread alloc] initWithBlock:^{ + int code = hev_socks5_tunnel_main_from_str((const unsigned char *)config.bytes, + (unsigned int)config.length, tunFd); + NSLog(@"WebDriverAgentTunnel: hev-socks5-tunnel exited with code %d", code); + weakSelf.isRunning = NO; + dispatch_semaphore_signal(exitSemaphore); + }]; + thread.name = @"hev-socks5-tunnel"; + thread.qualityOfService = NSQualityOfServiceUserInitiated; + [thread start]; +} + +- (BOOL)stopAndWait:(NSTimeInterval)timeout +{ + dispatch_semaphore_t exitSemaphore = self.exitSemaphore; + if (!self.isRunning || nil == exitSemaphore) { + return YES; + } + hev_socks5_tunnel_quit(); + return 0 == dispatch_semaphore_wait(exitSemaphore, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC))); +} + +- (void)getStatsTxPackets:(size_t *)txPackets + txBytes:(size_t *)txBytes + rxPackets:(size_t *)rxPackets + rxBytes:(size_t *)rxBytes +{ + hev_socks5_tunnel_stats(txPackets, txBytes, rxPackets, rxBytes); +} + +@end diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.h b/WebDriverAgentTunnel/FBTunnelPacketProvider.h new file mode 100644 index 0000000000..7c8c7c89e3 --- /dev/null +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.h @@ -0,0 +1,22 @@ +/** + * 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 + +/** + Packet tunnel provider routing the device's traffic through a SOCKS5 proxy via the + hev-socks5-tunnel engine. Configured by FBSocks5TunnelManager through the + providerConfiguration dictionary defined in FBSocks5TunnelProtocol.h. + */ +@interface FBTunnelPacketProvider : NEPacketTunnelProvider + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m new file mode 100644 index 0000000000..64fed9616f --- /dev/null +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -0,0 +1,179 @@ +/** + * 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 "FBTunnelPacketProvider.h" + +#include +#include + +#import "FBSocks5TunnelProtocol.h" +#import "FBTunFdFinder.h" +#import "FBTunnelHevRunner.h" + +static NSString *const FBTunnelErrorDomain = @"com.facebook.WebDriverAgent.WebDriverAgentTunnel"; + +static NSError *FBTunnelError(NSString *message) +{ + return [NSError errorWithDomain:FBTunnelErrorDomain + code:1 + userInfo:@{NSLocalizedDescriptionKey: message}]; +} + +// Resolves a proxy host to a literal IP. The engine must not resolve it itself: once the +// tunnel's DNS settings are active, an in-provider lookup would be routed back into the +// tunnel that is not functional yet. Prefers IPv4; falls back to IPv6. +static NSString *FBTunnelResolveHost(NSString *host, BOOL *isIPv6) +{ + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo *results = NULL; + if (0 != getaddrinfo(host.UTF8String, NULL, &hints, &results)) { + return nil; + } + NSString *ipv4 = nil; + NSString *ipv6 = nil; + for (struct addrinfo *entry = results; NULL != entry; entry = entry->ai_next) { + char buffer[INET6_ADDRSTRLEN] = {0}; + if (AF_INET == entry->ai_family && nil == ipv4) { + struct sockaddr_in *addr = (struct sockaddr_in *)entry->ai_addr; + if (NULL != inet_ntop(AF_INET, &addr->sin_addr, buffer, sizeof(buffer))) { + ipv4 = [NSString stringWithUTF8String:buffer]; + } + } else if (AF_INET6 == entry->ai_family && nil == ipv6) { + struct sockaddr_in6 *addr = (struct sockaddr_in6 *)entry->ai_addr; + if (NULL != inet_ntop(AF_INET6, &addr->sin6_addr, buffer, sizeof(buffer))) { + ipv6 = [NSString stringWithUTF8String:buffer]; + } + } + } + freeaddrinfo(results); + if (NULL != isIPv6) { + *isIPv6 = (nil == ipv4 && nil != ipv6); + } + return ipv4 ?: ipv6; +} + +@interface FBTunnelPacketProvider () +@property (nonatomic, nullable) FBTunnelHevRunner *runner; +@end + +@implementation FBTunnelPacketProvider + +- (void)startTunnelWithOptions:(nullable NSDictionary *)options + completionHandler:(void (^)(NSError *_Nullable))completionHandler +{ + NETunnelProviderProtocol *protocol = (NETunnelProviderProtocol *)self.protocolConfiguration; + NSDictionary *config = [protocol isKindOfClass:NETunnelProviderProtocol.class] + ? protocol.providerConfiguration + : nil; + NSString *host = config[FBSocks5KeyHost]; + NSNumber *port = config[FBSocks5KeyPort]; + BOOL remoteDNS = [config[FBSocks5KeyRemoteDNS] boolValue]; + if (0 == host.length || nil == port) { + completionHandler(FBTunnelError(@"The tunnel provider configuration is missing the proxy host/port")); + return; + } + + BOOL proxyIsIPv6 = NO; + NSString *proxyIP = FBTunnelResolveHost(host, &proxyIsIPv6); + if (nil == proxyIP) { + completionHandler(FBTunnelError([NSString stringWithFormat:@"Cannot resolve the SOCKS5 proxy host '%@'", host])); + return; + } + NSLog(@"WebDriverAgentTunnel: starting tunnel through %@:%@ (resolved %@, remoteDNS=%d)", + host, port, proxyIP, remoteDNS); + + NEPacketTunnelNetworkSettings *settings = + [[NEPacketTunnelNetworkSettings alloc] initWithTunnelRemoteAddress:proxyIP]; + NEIPv4Settings *ipv4 = [[NEIPv4Settings alloc] initWithAddresses:@[FBSocks5TunnelIPv4Address] + subnetMasks:@[FBSocks5TunnelIPv4Netmask]]; + ipv4.includedRoutes = @[NEIPv4Route.defaultRoute]; + if (!proxyIsIPv6) { + // The engine's own TCP connection to the proxy must not loop back into the tunnel. + // (An IPv6 proxy needs no exclusion: the tunnel only captures IPv4.) + ipv4.excludedRoutes = @[[[NEIPv4Route alloc] initWithDestinationAddress:proxyIP + subnetMask:@"255.255.255.255"]]; + } + settings.IPv4Settings = ipv4; + // socks5h: point DNS at hev's mapdns so queries become hostname-preserving CONNECTs. + // socks5: use public resolvers; the queries travel through the tunnel via the proxy's + // UDP relay (requires a proxy with UDP ASSOCIATE support). + NSArray *dnsServers = remoteDNS + ? @[FBSocks5TunnelMapDNSAddress] + : @[@"8.8.8.8", @"1.1.1.1"]; + NEDNSSettings *dns = [[NEDNSSettings alloc] initWithServers:dnsServers]; + dns.matchDomains = @[@""]; + settings.DNSSettings = dns; + settings.MTU = @(FBSocks5TunnelMTU); + + __weak typeof(self) weakSelf = self; + [self setTunnelNetworkSettings:settings completionHandler:^(NSError *_Nullable settingsError) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (nil == strongSelf) { + completionHandler(FBTunnelError(@"The tunnel provider was deallocated during startup")); + return; + } + if (nil != settingsError) { + completionHandler(settingsError); + return; + } + int tunFd = FBTunnelFindTunFd(); + if (tunFd < 0) { + completionHandler(FBTunnelError(@"Cannot locate the utun file descriptor in the tunnel provider")); + return; + } + // The engine must connect to the already resolved address (see FBTunnelResolveHost). + NSMutableDictionary *engineConfig = [config mutableCopy]; + engineConfig[FBSocks5KeyHost] = proxyIP; + NSString *yaml = FBSocks5HevConfigFromProviderConfiguration(engineConfig); + strongSelf.runner = [[FBTunnelHevRunner alloc] init]; + [strongSelf.runner startWithConfigYAML:yaml tunFd:tunFd]; + completionHandler(nil); + }]; +} + +- (void)stopTunnelWithReason:(NEProviderStopReason)reason + completionHandler:(void (^)(void))completionHandler +{ + NSLog(@"WebDriverAgentTunnel: stopping tunnel (reason %ld)", (long)reason); + [self.runner stopAndWait:5.0]; + self.runner = nil; + completionHandler(); +} + +- (void)handleAppMessage:(NSData *)messageData + completionHandler:(nullable void (^)(NSData *_Nullable))completionHandler +{ + if (nil == completionHandler) { + return; + } + NSString *message = [[NSString alloc] initWithData:messageData encoding:NSUTF8StringEncoding]; + if (![message isEqualToString:FBSocks5MsgStats]) { + completionHandler(nil); + return; + } + size_t txPackets = 0; + size_t txBytes = 0; + size_t rxPackets = 0; + size_t rxBytes = 0; + FBTunnelHevRunner *runner = self.runner; + if (nil != runner && runner.isRunning) { + [runner getStatsTxPackets:&txPackets txBytes:&txBytes rxPackets:&rxPackets rxBytes:&rxBytes]; + } + NSDictionary *stats = @{ + FBSocks5StatsKeyRxBytes: @(rxBytes), + FBSocks5StatsKeyTxBytes: @(txBytes), + FBSocks5StatsKeyRxPackets: @(rxPackets), + FBSocks5StatsKeyTxPackets: @(txPackets), + }; + completionHandler([NSJSONSerialization dataWithJSONObject:stats options:(NSJSONWritingOptions)0 error:nil]); +} + +@end diff --git a/WebDriverAgentTunnel/Info.plist b/WebDriverAgentTunnel/Info.plist new file mode 100644 index 0000000000..4ad5d9d372 --- /dev/null +++ b/WebDriverAgentTunnel/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + WebDriverAgent Tunnel + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + FBTunnelPacketProvider + + + diff --git a/WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements b/WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements new file mode 100644 index 0000000000..ffab33e018 --- /dev/null +++ b/WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + From 4a8c43b29ddccefe3f4e6fa0af65adefda82d559 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:11:18 +0200 Subject: [PATCH 05/23] feat(socks5): embed tunnel extension into Runner.app via scheme post-action Co-Authored-By: Claude Fable 5 --- Scripts/embed-tunnel-extension.sh | 138 ++++++++++++++++++ .../WebDriverAgentRunner-nodebug.xcscheme | 16 ++ .../xcschemes/WebDriverAgentRunner.xcscheme | 16 ++ 3 files changed, 170 insertions(+) create mode 100755 Scripts/embed-tunnel-extension.sh diff --git a/Scripts/embed-tunnel-extension.sh b/Scripts/embed-tunnel-extension.sh new file mode 100755 index 0000000000..c2147753a6 --- /dev/null +++ b/Scripts/embed-tunnel-extension.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# Embed the WebDriverAgentTunnel NetworkExtension packet tunnel extension into +# the wrapping XCTRunner host app. +# +# Apple's USES_XCTRUNNER auto-generates a Runner.app around UI-testing +# .xctest bundles after all target build phases have run, so an appex +# cannot reach Runner.app/PlugIns through a regular embed build phase. +# The extension target is built into BUILT_PRODUCTS_DIR via a target +# dependency of WebDriverAgentRunner; this scheme post-action copies it +# into Runner.app/PlugIns, fixes its bundle id to match the host app +# (extensions must be prefixed by the host's CFBundleIdentifier, which +# Xcode suffixes with '.xctrunner') and re-signs inner-first. +# +# Mirrors Scripts/embed-broadcast-extension.sh; kept separate because the +# tunnel additionally needs the NetworkExtension entitlement repaired on +# the host app (see below and docs/socks5-tunnel.md). +# +# Limitations: +# - Touches XCTRunner internals; may need updates across Xcode versions. +# - iOS only; the extension is not built for tvOS. +# - Loading the tunnel on a device needs paid-team signing with the +# packet-tunnel-provider entitlement (WDA_TUNNEL_ENTITLEMENTS / +# WDA_RUNNER_ENTITLEMENTS build settings); see docs/socks5-tunnel.md. + +set -euo pipefail + +RUNNER_APP="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}-Runner.app" +APPEX_NAME="WebDriverAgentTunnel.appex" +APPEX_SRC="${BUILT_PRODUCTS_DIR}/${APPEX_NAME}" +NE_ENTITLEMENT="com.apple.developer.networking.networkextension" + +if [ ! -d "$RUNNER_APP" ]; then + echo "warning: ${PRODUCT_NAME}-Runner.app not found at $RUNNER_APP; skipping tunnel extension embed" + exit 0 +fi + +if [ ! -d "$APPEX_SRC" ]; then + echo "warning: $APPEX_NAME not found at $APPEX_SRC; skipping tunnel extension embed" + exit 0 +fi + +APPEX_DST="$RUNNER_APP/PlugIns/$APPEX_NAME" +rm -rf "$APPEX_DST" +mkdir -p "$RUNNER_APP/PlugIns" +cp -R "$APPEX_SRC" "$APPEX_DST" + +# Extensions must carry a bundle id prefixed by the host app's. The host id is only final at +# this point (Xcode appends '.xctrunner'; downstream tooling may override the prefix), so +# always derive the appex id from the embedded Runner.app instead of trusting build settings. +HOST_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$RUNNER_APP/Info.plist") +WANT_ID="${HOST_ID}.tunnel" +CURRENT_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APPEX_DST/Info.plist") +ID_REWRITTEN=0 +if [ "$CURRENT_ID" != "$WANT_ID" ]; then + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $WANT_ID" "$APPEX_DST/Info.plist" + ID_REWRITTEN=1 +fi + +# Re-codesign since we modified the bundle after Xcode signed it: the appex first (its bundle +# id may just have changed, so do NOT preserve the identifier), then the app so its seal covers +# the new nested code. In a scheme post-action context Xcode's CODE_SIGN_* env vars are not +# exposed, so discover the existing signing identity from the already-signed bundle. +if [ -d "$RUNNER_APP/_CodeSignature" ]; then + # Capture the signature info once. Piping codesign straight into + # `awk ... exit` makes awk close the pipe early, killing codesign with + # SIGPIPE -- which `set -o pipefail` turns into a fatal error. That trips + # only when an Authority line exists, i.e. on every real-device build. + SIGN_INFO=$(codesign -dvv "$RUNNER_APP" 2>&1 || true) + EXISTING_IDENT="${EXPANDED_CODE_SIGN_IDENTITY:-}" + if [ -z "$EXISTING_IDENT" ]; then + EXISTING_IDENT=$(awk -F'=' '/^Authority/ {print $2; exit}' <<< "$SIGN_INFO") + fi + # Simulator builds are ad-hoc signed: there is no Authority line, but the + # bundle can still be re-signed ad-hoc with an identity of "-". + if [ -z "$EXISTING_IDENT" ] && grep -q '^Signature=adhoc' <<< "$SIGN_INFO"; then + EXISTING_IDENT="-" + fi + if [ -n "$EXISTING_IDENT" ]; then + APPEX_ENTITLEMENTS_PLIST=$(mktemp -t tunnel-appex-entitlements).plist + if ! codesign -d --entitlements - --xml "$APPEX_DST" > "$APPEX_ENTITLEMENTS_PLIST" 2>/dev/null; then + : > "$APPEX_ENTITLEMENTS_PLIST" + fi + if [ "$ID_REWRITTEN" = "1" ]; then + # The bundle id changed, so the signed entitlements' application-identifier must be + # regenerated to match it - preserving the stale entitlements makes installd reject + # the extension (identity no longer matches the bundle id). Rewrite the identifier in + # the extracted entitlements and re-sign with them. + if [ -s "$APPEX_ENTITLEMENTS_PLIST" ]; then + OLD_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :application-identifier" "$APPEX_ENTITLEMENTS_PLIST" 2>/dev/null || true) + TEAM_ID="${OLD_APP_ID%%.*}" + if [ -n "$TEAM_ID" ] && [ "$TEAM_ID" != "$OLD_APP_ID" ]; then + /usr/libexec/PlistBuddy -c "Set :application-identifier ${TEAM_ID}.${WANT_ID}" "$APPEX_ENTITLEMENTS_PLIST" + codesign --force --sign "$EXISTING_IDENT" \ + --entitlements "$APPEX_ENTITLEMENTS_PLIST" "$APPEX_DST" + else + # No application-identifier (e.g. simulator ad-hoc signing): nothing to fix up. + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=entitlements "$APPEX_DST" + fi + else + codesign --force --sign "$EXISTING_IDENT" "$APPEX_DST" + fi + echo "warning: appex bundle id was rewritten to $WANT_ID; the embedded provisioning" \ + "profile must cover that id with the packet-tunnel-provider entitlement," \ + "otherwise re-sign with a matching profile - see docs/socks5-tunnel.md" + else + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=entitlements "$APPEX_DST" + fi + + # Xcode is not documented to propagate the UI-test target's CODE_SIGN_ENTITLEMENTS onto + # the generated Runner.app. The host must hold the NetworkExtension entitlement for + # NETunnelProviderManager to accept the configuration, so when the appex carries it but + # the host does not, inject it into the host's entitlements while re-sealing. + RUNNER_ENTITLEMENTS_PLIST=$(mktemp -t tunnel-runner-entitlements).plist + if ! codesign -d --entitlements - --xml "$RUNNER_APP" > "$RUNNER_ENTITLEMENTS_PLIST" 2>/dev/null; then + : > "$RUNNER_ENTITLEMENTS_PLIST" + fi + APPEX_HAS_NE=$(/usr/libexec/PlistBuddy -c "Print :${NE_ENTITLEMENT}" "$APPEX_ENTITLEMENTS_PLIST" 2>/dev/null || true) + RUNNER_HAS_NE=$(/usr/libexec/PlistBuddy -c "Print :${NE_ENTITLEMENT}" "$RUNNER_ENTITLEMENTS_PLIST" 2>/dev/null || true) + if [ -n "$APPEX_HAS_NE" ] && [ -z "$RUNNER_HAS_NE" ] && [ -s "$RUNNER_ENTITLEMENTS_PLIST" ]; then + /usr/libexec/PlistBuddy -c "Add :${NE_ENTITLEMENT} array" "$RUNNER_ENTITLEMENTS_PLIST" + /usr/libexec/PlistBuddy -c "Add :${NE_ENTITLEMENT}:0 string packet-tunnel-provider" "$RUNNER_ENTITLEMENTS_PLIST" + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier \ + --entitlements "$RUNNER_ENTITLEMENTS_PLIST" "$RUNNER_APP" + echo "injected $NE_ENTITLEMENT into Runner.app entitlements during re-sign" + else + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier,entitlements "$RUNNER_APP" + fi + rm -f "$APPEX_ENTITLEMENTS_PLIST" "$RUNNER_ENTITLEMENTS_PLIST" + else + echo "warning: bundle is signed but no identity discovered; signature will be invalid" + fi +fi + +echo "embedded $APPEX_NAME into $RUNNER_APP (bundle id $WANT_ID)" diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme index 57692f8224..0308efaf81 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme @@ -38,6 +38,22 @@ + + + + + + + + + + + + + + + + Date: Fri, 12 Jun 2026 15:12:28 +0200 Subject: [PATCH 06/23] feat(socks5): build the tunnel engine before runner builds in build.sh Co-Authored-By: Claude Fable 5 --- Scripts/build.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Scripts/build.sh b/Scripts/build.sh index 63985e8c2e..e0a0cad71d 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -114,7 +114,25 @@ function fastlane_test() { SDK="$XC_SDK" DEVICE="$FASTLANE_DEVICE" SCHEME="$1" bundle exec fastlane test } +function prepare_socks5_engine() { + # The runner scheme builds the WebDriverAgentTunnel appex, which links + # HevSocks5Tunnel.xcframework built from the hev-socks5-tunnel submodule + # (no-op when the build stamp is current). See docs/socks5-tunnel.md. + if [[ "$TARGET" != "runner" ]]; then + return + fi + local script_dir + script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + if [ ! -f "$script_dir/../ThirdParty/hev-socks5-tunnel/src/hev-main.h" ]; then + echo "error: the hev-socks5-tunnel submodule is not initialized;" \ + "run 'git submodule update --init --recursive' first" + exit 1 + fi + "$script_dir/build-hev-socks5-tunnel.sh" +} + define_xc_macros +prepare_socks5_engine case "$ACTION" in "analyze" ) analyze ;; "int_test_1" ) fastlane_test IntegrationTests_1 ;; From 50165b541448a3469ee7b3afa5ae351d8e80d5db Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:15:03 +0200 Subject: [PATCH 07/23] docs(socks5): document the SOCKS5 tunnel endpoints, build and signing Co-Authored-By: Claude Fable 5 --- docs/socks5-tunnel.md | 133 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/socks5-tunnel.md diff --git a/docs/socks5-tunnel.md b/docs/socks5-tunnel.md new file mode 100644 index 0000000000..93cc26ac1d --- /dev/null +++ b/docs/socks5-tunnel.md @@ -0,0 +1,133 @@ +# SOCKS5 VPN Tunnel + +WebDriverAgent embeds a NetworkExtension **packet tunnel provider** +(`WebDriverAgentTunnel.appex`) into the generated `WebDriverAgentRunner-Runner.app`. When +connected, the tunnel captures the device's IPv4 traffic on a virtual interface and forwards +it through a SOCKS5 proxy using the [hev-socks5-tunnel](https://github.com/heiher/hev-socks5-tunnel) +engine (MIT) running inside the extension. WDA configures and controls the tunnel in-process +via `NETunnelProviderManager`. + +The tunnel only works on **physical iOS devices** (packet tunnel providers do not run on the +Simulator or tvOS — the endpoints answer `unsupported operation` there) and requires +**paid-team signing**: free/personal Apple developer teams cannot register App IDs with the +Network Extension capability. + +## HTTP endpoints + +| Endpoint | Method | Description | +|---|---|---| +| `/mobilerun/socks5/connect` | POST | Installs/updates the VPN configuration (auto-accepting the system consent alert via UI automation) and starts the tunnel. Replaces an already running tunnel. Returns once the tunnel reports connected. | +| `/mobilerun/socks5/disconnect` | POST | Stops the running tunnel. The VPN profile stays installed. Succeeds when no tunnel is running. Send `{}` as the body (WDA rejects body-less POSTs with HTTP 400). | +| `/mobilerun/socks5/stats` | GET | Connection state plus traffic counters queried from the extension. Never fails; counters fall back to zero when the extension cannot be reached. | + +`connect` body: + +```json +{ + "uri": "socks5h://user:pass@proxy.example.com:1080", + "timeout": 30, + "consentButtonLabels": ["Allow"] +} +``` + +- `uri` (required) — `socks5://` or `socks5h://`, optional percent-encoded `user:pass@`, + host (DNS name, IPv4 or bracketed IPv6 literal), optional port (default 1080). + With `socks5h` hostnames are resolved **through the proxy**: the tunnel runs hev's + `mapdns`, which hijacks DNS queries to synthetic IPs and forwards the original hostname in + the SOCKS5 CONNECT. With plain `socks5` the tunnel uses public resolvers (8.8.8.8/1.1.1.1) + and relays the DNS datagrams through the proxy's UDP relay — the proxy must support UDP + ASSOCIATE for that; prefer `socks5h` when unsure. +- `timeout` (optional, default 30) — seconds for the whole connect flow (consent + tunnel + reaching connected). +- `consentButtonLabels` (optional, default `["Allow"]`) — labels to look for on the system + "Would Like to Add VPN Configurations" alert. Pass the localized label when the device + language is not English. + +`stats` response value (also returned by successful `connect`/`disconnect` calls): + +```json +{ + "connected": true, + "host": "proxy.example.com", + "port": 1080, + "user": "user", + "rxBytes": 123456, + "txBytes": 7890, + "rxPackets": 321, + "txPackets": 123 +} +``` + +`host`/`port`/`user` are omitted while disconnected (`user` also when the proxy needs no +auth). Counters are cumulative since the tunnel start and reset on reconnect. + +## Build + +The engine is vendored as a git submodule and compiled into a static-library xcframework +that only the appex links: + +```bash +git submodule update --init --recursive +Scripts/build-hev-socks5-tunnel.sh # -> ThirdParty/HevSocks5Tunnel.xcframework (gitignored) +``` + +`Scripts/build.sh` runs the engine build automatically for runner targets, and it is a no-op +while the stamp file matches the submodule SHA (`--force` rebuilds). Plain `xcodebuild` +invocations need the script run once beforehand. + +Like the broadcast extension, the appex cannot reach the Xcode-generated `Runner.app` through +a regular embed phase; the scheme post-action `Scripts/embed-tunnel-extension.sh` copies it +into `Runner.app/PlugIns`, rewrites its bundle id to `.tunnel` and re-signs +inner-first (post-actions run on scheme-based CLI builds, including `build-for-testing`). + +## Signing (device) + +Both the host app and the appex must carry the +`com.apple.developer.networking.networkextension` entitlement with the +`packet-tunnel-provider` value, and the team's App IDs for the runner and +`.xctrunner.tunnel` need the Network Extension capability — **paid teams only**. + +The entitlements are wired through build variables that default to empty, so regular +(free-team / CI / Simulator) builds keep working unchanged. For a paid-team device build pass +them explicitly: + +```bash +xcodebuild build-for-testing -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner \ + -destination 'id=' -allowProvisioningUpdates \ + DEVELOPMENT_TEAM= \ + WDA_RUNNER_ENTITLEMENTS=WebDriverAgentRunner/WebDriverAgentRunner.entitlements \ + WDA_TUNNEL_ENTITLEMENTS=WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements +``` + +`Scripts/embed-tunnel-extension.sh` additionally injects the NE entitlement into +`Runner.app`'s signature when the appex carries it but the host does not (Xcode does not +document whether a UI-test target's `CODE_SIGN_ENTITLEMENTS` propagates to the generated +runner). Cloud device farms that re-sign WDA must re-sign the nested appex with the same +team first and preserve the NE entitlement on both bundles. + +Without the entitlements the build still succeeds and the appex is embedded, but +`saveToPreferences` is rejected on device and `connect` answers +`unsupported operation: The VPN configuration was not authorized`. + +## Consent alert + +The first `saveToPreferences` per install triggers the system "Would Like to Add VPN +Configurations" alert. The connect handler auto-confirms it by tapping the consent button on +Springboard while the save is pending. **Devices with a passcode additionally prompt for the +passcode, which cannot be automated** — confirm once manually; the profile then persists +until the app is uninstalled. Reinstalling WDA (new install, not upgrade) removes the +profile, so the next connect repeats the consent flow. + +## Device verification checklist (deferred until a paid team is available) + +1. Run a local SOCKS5 server on the Mac: `microsocks -p 1080` (or `ssh -D 0.0.0.0:1080 -N localhost`). +2. Build + install with the paid-team invocation above; start WDA. +3. `GET /mobilerun/socks5/stats` → `connected: false`. +4. `POST /mobilerun/socks5/connect` with `socks5h://:1080` → `connected: true`; + check `codesign -d --entitlements -` on both `Runner.app` and the appex if it fails. +5. Egress check: fetch `https://api.ipify.org` on the device → the Mac's egress IP. +6. Download a known-size file, re-poll `stats`: `rxBytes` must grow by roughly that size + (confirms the rx/tx direction mapping of the engine counters; swap the mapping in + `FBSocks5TunnelManager.statsDictionary`/`FBTunnelPacketProvider.handleAppMessage` if + inverted). +7. `POST /mobilerun/socks5/disconnect` → `connected: false`, egress IP reverts. From 952189bf2aa6d87b8e84995f58362dd113085547 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:16:52 +0200 Subject: [PATCH 08/23] fix(socks5): silence NSJSONReadingOptions cast warning in stats query Co-Authored-By: Claude Fable 5 --- WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 6691af9107..85c3c60b35 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -358,7 +358,9 @@ - (BOOL)disconnectWithError:(NSError **)error returnError:&messageError responseHandler:^(NSData *responseData) { if (nil != responseData) { - id parsed = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:nil]; + id parsed = [NSJSONSerialization JSONObjectWithData:responseData + options:(NSJSONReadingOptions)0 + error:nil]; counters = [parsed isKindOfClass:NSDictionary.class] ? parsed : nil; } done = YES; From 16c5a8dcdecb33c85e2970dcf7e8e51a85e98478 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:23:58 +0200 Subject: [PATCH 09/23] fix: run the socks5 routes on the control queue Every NetworkExtension call from these handlers failed after exactly FBSocks5PreferencesTimeout (10s) with "Cannot load the VPN preferences: timed out", on both free and paid teams. Without .onControlQueue, FBWebServer serves a route via dispatch_sync(automationQueue) -> dispatch_sync(main), so the handler body runs on the main thread inside a dispatch_sync'd main-queue block. NETunnelProviderManager delivers loadAllFromPreferences completions on the main queue, which cannot be drained while that sync block is spinning FBRunLoopSpinner, so the completion never ran and the spinner always timed out. The device log showed "NetworkExtension: Loading all configurations" with no result line following it. Serving these routes on the connection's own queue lets the main queue drain the completion. /stats drops from 10.058s to 0.033s, and connect now brings the tunnel up on device (verified on iPhone SE / iOS 26.5 with traffic traversing an external SOCKS5 proxy). Co-Authored-By: Claude Fable 5 --- .../Commands/FBMobilerunSocks5Commands.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m index 5e13454161..22064d2dd0 100644 --- a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m @@ -25,12 +25,12 @@ + (NSArray *)routes { return @[ - [[FBRoute POST:@"/mobilerun/socks5/connect"] respondWithTarget:self action:@selector(handleConnect:)], - [[FBRoute POST:@"/mobilerun/socks5/disconnect"] respondWithTarget:self action:@selector(handleDisconnect:)], - [[FBRoute GET:@"/mobilerun/socks5/stats"] respondWithTarget:self action:@selector(handleStats:)], - [[FBRoute POST:@"/mobilerun/socks5/connect"].withoutSession respondWithTarget:self action:@selector(handleConnect:)], - [[FBRoute POST:@"/mobilerun/socks5/disconnect"].withoutSession respondWithTarget:self action:@selector(handleDisconnect:)], - [[FBRoute GET:@"/mobilerun/socks5/stats"].withoutSession respondWithTarget:self action:@selector(handleStats:)], + [[FBRoute POST:@"/mobilerun/socks5/connect"].onControlQueue respondWithTarget:self action:@selector(handleConnect:)], + [[FBRoute POST:@"/mobilerun/socks5/disconnect"].onControlQueue respondWithTarget:self action:@selector(handleDisconnect:)], + [[FBRoute GET:@"/mobilerun/socks5/stats"].onControlQueue respondWithTarget:self action:@selector(handleStats:)], + [[FBRoute POST:@"/mobilerun/socks5/connect"].withoutSession.onControlQueue respondWithTarget:self action:@selector(handleConnect:)], + [[FBRoute POST:@"/mobilerun/socks5/disconnect"].withoutSession.onControlQueue respondWithTarget:self action:@selector(handleDisconnect:)], + [[FBRoute GET:@"/mobilerun/socks5/stats"].withoutSession.onControlQueue respondWithTarget:self action:@selector(handleStats:)], ]; } From b30ad08c129c61e772b19420effc9b34e93c70e5 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:38:25 +0200 Subject: [PATCH 10/23] fix: confirm the VPN consent alert the way the broadcast alert is confirmed The consent tap logged success while the alert stayed on screen, so saveToPreferences never completed and connect failed with "Timed out saving the VPN configuration. The consent alert was confirmed". It diverged from FBBroadcastManager's dismissal tap in three ways, each of which mattered: - It synthesized the event through the runner, while reading the button frame out of SpringBoard's coordinate space. The record is stamped with the receiver's interface orientation, so the tap could land somewhere else entirely. Synthesize via the system app instead. - It used the blocking variant inside a bounded spin. That acknowledgement can take the full event-synthesis timeout margin when the system sheds the event, which the spin's own deadline cannot interrupt. Dispatch fire-and-forget instead. - It latched after one apparently successful dispatch. A dispatched tap is not a landed tap, so a shed event meant the alert stood forever. Keep re-attempting while the alert is observed, paced by a 1s cooldown so a re-tap cannot land during the dismissal animation. Verified on iPhone SE / iOS 26.5 against a fresh install: the first tap is shed and the retry 1.09s later lands, the alert clears, and connect returns connected in 2.2s. The XCUI access also has to reach the main thread now that these routes are served off it, so FBWebServer exposes the automation funnel it already used for main-queue-served routes. Hopping straight to the main queue would let the block run inside another handler's run-loop spin, which is the reentrancy that funnel exists to prevent. Co-Authored-By: Claude Fable 5 --- WebDriverAgentLib/Routing/FBWebServer.h | 17 +++ WebDriverAgentLib/Routing/FBWebServer.m | 37 +++++- .../Utilities/FBSocks5TunnelManager.m | 107 +++++++++++++----- 3 files changed, 129 insertions(+), 32 deletions(-) diff --git a/WebDriverAgentLib/Routing/FBWebServer.h b/WebDriverAgentLib/Routing/FBWebServer.h index 7ab0b8809d..c5995fa5e5 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.h +++ b/WebDriverAgentLib/Routing/FBWebServer.h @@ -37,6 +37,23 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)stopServing; +/** + Runs a block that touches XCUI/XCTest state on the main thread, serialized through the + same automation funnel the main-queue-served routes use. + + Routes marked `onControlQueue` are served off the main queue, so they must not call + XCUI directly. Hopping straight to the main queue is not enough either: such a block + could be drained inside another handler's run-loop spin, which is exactly the + reentrancy the funnel exists to prevent. Going through the funnel first makes the + block wait for the in-flight automation request instead. + + No-ops onto a direct call when already on the main thread (the caller is then already + inside the funnel), and skips the funnel hop when already running on it. + + @param block The XCUI-touching work. Executed synchronously before this method returns. + */ ++ (void)performAutomationBlockOnMainQueue:(NS_NOESCAPE dispatch_block_t)block; + @end /** diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index 738637f18c..04794de1ac 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -57,6 +57,9 @@ - (UInt64)maxRequestBodySize #endif +/// Queue-specific marker used to detect that the caller already runs on the automation funnel. +static const void *FBAutomationFunnelKey = &FBAutomationFunnelKey; + @interface FBWebServer () @property (nonatomic, strong) FBExceptionHandler *exceptionHandler; #if TARGET_OS_WATCH @@ -146,6 +149,38 @@ - (void)startServing } } +/** + The funnel is process-wide rather than per-server instance so that off-main callers + (see performAutomationBlockOnMainQueue:) serialize against the very same queue the + route dispatch uses. + */ ++ (dispatch_queue_t)automationFunnelQueue +{ + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.facebook.WebDriverAgent.automation-funnel", DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(queue, FBAutomationFunnelKey, (void *)FBAutomationFunnelKey, NULL); + }); + return queue; +} + ++ (void)performAutomationBlockOnMainQueue:(NS_NOESCAPE dispatch_block_t)block +{ + if (NSThread.isMainThread) { + // Already inside funnel -> main, so re-entering either would deadlock. + block(); + return; + } + if (NULL != dispatch_get_specific(FBAutomationFunnelKey)) { + dispatch_sync(dispatch_get_main_queue(), block); + return; + } + dispatch_sync(self.automationFunnelQueue, ^{ + dispatch_sync(dispatch_get_main_queue(), block); + }); +} + - (BOOL)startHTTPServer { #if TARGET_OS_WATCH @@ -163,7 +198,7 @@ - (BOOL)startHTTPServer [self.server setConnectionClass:[FBHTTPConnection self]]; #endif - self.automationQueue = dispatch_queue_create("com.facebook.WebDriverAgent.automation-funnel", DISPATCH_QUEUE_SERIAL); + self.automationQueue = self.class.automationFunnelQueue; [self registerRouteHandlers:[self.class collectCommandHandlerClasses]]; [self registerServerKeyRouteHandlers]; diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 85c3c60b35..31e86851bb 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -77,6 +77,8 @@ - (BOOL)disconnectWithError:(NSError **)error #import "FBLogger.h" #import "FBRunLoopSpinner.h" #import "FBScreen.h" +#import "FBWebServer.h" +#import "FBXCTestDaemonsProxy.h" #import "XCUIApplication.h" #import "XCUIApplication+FBHelpers.h" #import "XCUIApplication+FBTouchAction.h" @@ -90,9 +92,18 @@ - (BOOL)disconnectWithError:(NSError **)error static const NSTimeInterval FBSocks5StopTimeout = 10.0; static const NSTimeInterval FBSocks5StatsReplyTimeout = 3.0; static const NSTimeInterval FBSocks5DefaultConnectTimeout = 30.0; +/** Minimum gap between two consent taps, so a re-attempt cannot land during the dismissal animation. */ +static const uint64_t FBSocks5ConsentTapCooldownMs = 1000; + +static uint64_t FBSocks5NowMs(void) +{ + return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) / NSEC_PER_MSEC; +} @interface FBSocks5TunnelManager () @property (nonatomic, nullable) NETunnelProviderManager *activeManager; +/** Monotonic ms timestamp of the last consent tap dispatch; guards the re-attempt cooldown. */ +@property (nonatomic) uint64_t lastConsentTapMs; @end @implementation FBSocks5TunnelManager @@ -163,36 +174,68 @@ - (BOOL)waitUntilStopped:(NETunnelProviderManager *)manager // Configurations' alert that must be confirmed before the save completion fires, so this is // polled from within the save wait loop. Devices with a passcode additionally ask for it, // which cannot be automated (documented in docs/socks5-tunnel.md). -- (BOOL)tapConsentButtonWithLabels:(NSArray *)labels runner:(XCUIApplication *)runner +// XCUI is only safe to touch from the main thread, and the socks5 routes are served off it +// (they are marked onControlQueue so the main queue stays free to drain the NetworkExtension +// completion handlers this class waits on). Every XCUI access below therefore hops onto the +// automation funnel and then the main queue, which is the same path the main-queue-served +// routes - e.g. the broadcast start/stop pair, whose consent handling this mirrors - take. +// +// The tap itself follows FBBroadcastManager's dismissal tap in three respects, each of which +// this code previously got wrong and which together left the alert standing while the caller +// believed it had been confirmed: +// 1. It is synthesized via the SYSTEM app, not the runner. The frame is read out of +// SpringBoard's coordinate space and the event record is stamped with the RECEIVER's +// interface orientation, so synthesizing through the (backgrounded, orientation-stale) +// runner can land the tap somewhere else entirely. +// 2. It is fire-and-forget. The blocking variant's acknowledgement can take the full +// event-synthesis timeout margin when the system sheds the event, which cannot be +// interrupted by the caller's much shorter spin deadline. +// 3. Its outcome is therefore observed via state (has the alert gone / did the save +// complete?), never inferred from the dispatch succeeding - so the caller must keep +// re-attempting, paced by the cooldown below, instead of latching after one dispatch. +- (BOOL)tapConsentButtonWithLabels:(NSArray *)labels { - XCUIApplication *system = XCUIApplication.fb_systemApplication; - for (NSString *label in labels) { - XCUIElement *button = system.buttons[label]; - if (!button.exists) { - continue; + __block BOOL dispatched = NO; + [FBWebServer performAutomationBlockOnMainQueue:^{ + XCUIApplication *system = XCUIApplication.fb_systemApplication; + for (NSString *label in labels) { + XCUIElement *button = system.buttons[label]; + if (!button.exists) { + continue; + } + CGRect frame = button.frame; + if (CGRectIsEmpty(frame)) { + continue; + } + // Without a cooldown the next spin iteration could re-tap the same coordinates while the + // alert's dismissal animation is still running, landing the extra tap on the UI beneath. + if (FBSocks5NowMs() - self.lastConsentTapMs < FBSocks5ConsentTapCooldownMs) { + return; + } + CGFloat scale = (CGFloat)[FBScreen scale]; + CGPoint center = CGPointMake(CGRectGetMidX(frame) * scale, CGRectGetMidY(frame) * scale); + NSArray *tapActions = @[ + @{@"type": @"pointerDown", @"x": @(center.x), @"y": @(center.y)}, + @{@"type": @"pause", @"duration": @60}, + @{@"type": @"pointerUp", @"x": @(center.x), @"y": @(center.y)}, + ]; + NSError *tapError; + XCSynthesizedEventRecord *record = [system fb_mobilerunEventRecordFromActions:tapActions + scale:scale + error:&tapError]; + if (nil == record) { + [FBLogger logFmt:@"socks5/connect: cannot build the tap for the VPN consent button '%@': %@", + label, tapError.localizedDescription]; + return; + } + self.lastConsentTapMs = FBSocks5NowMs(); + [FBXCTestDaemonsProxy synthesizeEventAsyncWithRecord:record]; + [FBLogger logFmt:@"socks5/connect: dispatched a tap at the VPN consent button '%@'", label]; + dispatched = YES; + return; } - CGRect frame = button.frame; - if (CGRectIsEmpty(frame)) { - continue; - } - // Tap via WDA's own event synthesis instead of XCUIElement.tap: a missed XCUIElement tap - // records an XCTest failure that tears down the test session (see FBBroadcastManager). - CGFloat scale = (CGFloat)[FBScreen scale]; - CGPoint center = CGPointMake(CGRectGetMidX(frame) * scale, CGRectGetMidY(frame) * scale); - NSArray *tapActions = @[ - @{@"type": @"pointerDown", @"x": @(center.x), @"y": @(center.y)}, - @{@"type": @"pause", @"duration": @60}, - @{@"type": @"pointerUp", @"x": @(center.x), @"y": @(center.y)}, - ]; - NSError *tapError; - if ([runner fb_performMobilerunActions:tapActions scale:scale error:&tapError]) { - [FBLogger logFmt:@"socks5/connect: tapped the VPN consent button '%@'", label]; - return YES; - } - [FBLogger logFmt:@"socks5/connect: cannot tap the VPN consent button '%@': %@", - label, tapError.localizedDescription]; - } - return NO; + }]; + return dispatched; } #pragma mark - Public API @@ -238,14 +281,16 @@ - (BOOL)connectWithURI:(FBSocks5URI *)uri saveError = err; saveDone = YES; }]; - XCUIApplication *runner = [[XCUIApplication alloc] initWithBundleIdentifier:(NSString *)NSBundle.mainBundle.bundleIdentifier]; + // Keep re-attempting for as long as the alert is still up: a dispatched tap can be shed by the + // system, so 'we dispatched one' is not evidence that it landed. tapConsentButtonWithLabels: + // paces the re-attempts itself and answers NO once the alert is gone. __block BOOL consentTapped = NO; [[[[FBRunLoopSpinner new] timeout:MAX(deadline.timeIntervalSinceNow, 1.0)] interval:0.3] spinUntilTrue:^BOOL{ if (saveDone) { return YES; } - if (!consentTapped) { - consentTapped = [self tapConsentButtonWithLabels:labels runner:runner]; + if ([self tapConsentButtonWithLabels:labels]) { + consentTapped = YES; } return NO; }]; From 8a67924a7fe56ad41abeb358dfe1dc23c7ab810d Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:00:34 +0200 Subject: [PATCH 11/23] fix: ship and build the tunnel inputs outside a recursive git checkout Three distribution gaps left the tunnel target unbuildable for everyone who is not working in a recursive clone: - The npm files allowlist shipped Scripts/build.sh but none of the inputs it now needs, so an installed appium-webdriveragent could not build WDA at all: the runner build exited on the missing engine source and Xcode referenced absent target files. Ship WebDriverAgentTunnel, the engine sources and the two new scripts. - build-hev-socks5-tunnel.sh keyed its up-to-date stamp on the submodule's Git SHA, which does not exist in the npm tarball. Fall back to hashing the engine source tree, which keys the stamp on what is actually being compiled either way. - No workflow checked out submodules, so every TARGET=runner job would hit build.sh's hard failure before reaching Xcode. The publish job needs them too, otherwise it uploads a tarball with an empty ThirdParty. Also add the tunnel paths to the WDA Tests trigger, so a change confined to WebDriverAgentTunnel/, ThirdParty/, .gitmodules or either new script cannot merge without runner build coverage. Co-Authored-By: Claude Fable 5 --- .github/workflows/functional-test.yml | 4 ++++ .github/workflows/prebuild.yml | 8 ++++++++ .github/workflows/publish.js.yml | 8 ++++++++ .github/workflows/wda-tests.yml | 21 +++++++++++++++++++++ Scripts/build-hev-socks5-tunnel.sh | 13 +++++++++++-- package.json | 4 ++++ 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index df7f22240c..6dd5cda70f 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -50,6 +50,10 @@ jobs: runs-on: ${{matrix.test_targets.HOST_OS}} steps: - uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - uses: actions/setup-node@v7 with: node-version: lts/* diff --git a/.github/workflows/prebuild.yml b/.github/workflows/prebuild.yml index da5c0b2ce1..3ed2b1e5c8 100644 --- a/.github/workflows/prebuild.yml +++ b/.github/workflows/prebuild.yml @@ -43,6 +43,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "${{ env.XCODE_VERSION }}" @@ -89,6 +93,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "${{ env.XCODE_VERSION }}" diff --git a/.github/workflows/publish.js.yml b/.github/workflows/publish.js.yml index 0099b44fb3..e8f552adb1 100644 --- a/.github/workflows/publish.js.yml +++ b/.github/workflows/publish.js.yml @@ -58,6 +58,10 @@ jobs: config: ${{ fromJSON(needs.build_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "${{ env.XCODE_VERSION }}" @@ -85,6 +89,10 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - name: Use Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/wda-tests.yml b/.github/workflows/wda-tests.yml index 965bee6765..290031898b 100644 --- a/.github/workflows/wda-tests.yml +++ b/.github/workflows/wda-tests.yml @@ -13,7 +13,12 @@ on: - 'PrivateHeaders/**' - 'Configurations/**' - 'WebDriverAgent.xcodeproj/**' + - 'WebDriverAgentTunnel/**' + - 'ThirdParty/**' + - '.gitmodules' - 'Scripts/build.sh' + - 'Scripts/build-hev-socks5-tunnel.sh' + - 'Scripts/embed-tunnel-extension.sh' - 'Fastlane/**' - 'Gemfile*' - '.github/workflows/wda-tests.yml' @@ -132,6 +137,10 @@ jobs: config: ${{ fromJSON(needs.build_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - uses: ./.github/actions/xcode-setup with: xcode_version: ${{ matrix.config.xcode_version }} @@ -161,6 +170,10 @@ jobs: config: ${{ fromJSON(needs.analyze_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - uses: ./.github/actions/xcode-setup with: xcode_version: ${{ matrix.config.xcode_version }} @@ -188,6 +201,10 @@ jobs: config: ${{ fromJSON(needs.unit_test_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - uses: ./.github/actions/xcode-setup with: xcode_version: ${{ matrix.config.xcode_version }} @@ -213,6 +230,10 @@ jobs: config: ${{ fromJSON(needs.integration_test_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 + with: + # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested + # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + submodules: recursive - uses: ./.github/actions/xcode-setup with: xcode_version: ${{ matrix.config.xcode_version }} diff --git a/Scripts/build-hev-socks5-tunnel.sh b/Scripts/build-hev-socks5-tunnel.sh index e085af3050..bff5c17f44 100755 --- a/Scripts/build-hev-socks5-tunnel.sh +++ b/Scripts/build-hev-socks5-tunnel.sh @@ -26,9 +26,18 @@ if [ ! -f "$SUBMODULE_DIR/src/hev-main.h" ]; then exit 1 fi -SUBMODULE_SHA=$(git -C "$SUBMODULE_DIR" rev-parse HEAD) +# The npm tarball ships the engine sources without any Git metadata, so the submodule SHA is +# only available in a Git checkout. Fall back to hashing the source tree there, which keys the +# stamp on exactly what is about to be compiled either way. +if SUBMODULE_SHA=$(git -C "$SUBMODULE_DIR" rev-parse HEAD 2>/dev/null); then + SOURCE_ID="$SUBMODULE_SHA" +else + SOURCE_ID=$(find "$SUBMODULE_DIR/src" -type f -exec shasum -a 256 {} + \ + | sort | shasum -a 256 | cut -d' ' -f1) + SUBMODULE_SHA="tree-$SOURCE_ID" +fi SCRIPT_SHA=$(shasum -a 256 "$0" | cut -d' ' -f1) -STAMP="${SUBMODULE_SHA}-${SCRIPT_SHA}-${MIN_IOS}" +STAMP="${SOURCE_ID}-${SCRIPT_SHA}-${MIN_IOS}" if [ "${1:-}" != "--force" ] && [ -d "$OUTPUT" ] && [ -f "$STAMP_FILE" ] \ && [ "$(cat "$STAMP_FILE")" = "$STAMP" ]; then diff --git a/package.json b/package.json index 771d5e0f4e..b3adf39865 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "Scripts/build.sh", "Scripts/embed-runner-icon.sh", "Scripts/embed-broadcast-extension.sh", + "Scripts/embed-tunnel-extension.sh", + "Scripts/build-hev-socks5-tunnel.sh", "Scripts/*.mjs", "Configurations", "PrivateHeaders", @@ -32,6 +34,8 @@ "WebDriverAgentBroadcast", "WebDriverAgentLib", "WebDriverAgentRunner", + "WebDriverAgentTunnel", + "ThirdParty/hev-socks5-tunnel", "WebDriverAgentTests", "XCTWebDriverAgentLib", "CHANGELOG.md" From d69a2637284184b16cf3e507c5861ebcaba24263 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:15:54 +0200 Subject: [PATCH 12/23] fix: address the P1/P2 review of the socks5 tunnel Verify the SOCKS5 path before reporting connected. hev does not dial the proxy until tunneled traffic creates a session, so the provider reported startup success as soon as the engine thread was spawned: NetworkExtension reached NEVPNStatusConnected, the endpoint answered connected: true, and every packet was blackholed. The provider now performs a SOCKS5 greeting (plus username/password sub-negotiation when credentials are configured) before installing any routes, holds startup pending until the engine has survived its initialization window, and cancels the tunnel if the engine later exits on its own. connectWithURI: additionally stops as soon as the session settles back to disconnected instead of spinning out the caller's deadline - an unreachable proxy now fails in ~2.5s with an accurate message rather than after 40s claiming success. Serialize the SOCKS5 lifecycle. .onControlQueue leaves these handlers on their own per-connection queues and FBWebServer only funnels the non-control routes, so a disconnect could return while an in-flight connect went on to start the tunnel, and two connects could race different proxy configurations through stop/save/reload/start. All three entry points now run on one serial lifecycle queue, which also gives stats a consistent snapshot. Honor the connect timeout across every wait. loadAllManagers:, waitUntilStopped: and the post-save reload each restarted a fixed 10s stage, and both spinners floored their budget at one second, so a one-second request could block for half a minute. Every wait now gets whatever is left of the caller's deadline, capped by its own stage limit, and refuses to start once the budget is gone. Match the VPN consent alert before tapping. An app-wide system.buttons[@"Allow"] query would select any other SpringBoard prompt that happened to be up, silently granting an unrelated permission. The button is now located inside a two-button alert, the way FBBroadcastManager anchors its own. Co-Authored-By: Claude Fable 5 --- .../Utilities/FBSocks5TunnelManager.m | 191 ++++++++++++++++-- WebDriverAgentTunnel/FBTunnelHevRunner.h | 4 +- WebDriverAgentTunnel/FBTunnelHevRunner.m | 10 +- WebDriverAgentTunnel/FBTunnelPacketProvider.m | 182 ++++++++++++++++- 4 files changed, 363 insertions(+), 24 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 31e86851bb..22a24ec468 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -100,10 +100,32 @@ static uint64_t FBSocks5NowMs(void) return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) / NSEC_PER_MSEC; } +/// Queue-specific marker used to detect that the caller already holds the lifecycle queue. +static const void *FBSocks5LifecycleQueueKey = &FBSocks5LifecycleQueueKey; + +/** + How long an individual wait may block: whatever is left of the caller's whole-flow deadline, + never more than that stage's own cap. `deadline` is nil for the flows that do not carry one + (disconnect, stats), which then just get the cap. A non-positive result means the caller's + budget is exhausted and the stage must not start at all. + */ +static NSTimeInterval FBSocks5RemainingTimeout(NSDate *_Nullable deadline, NSTimeInterval cap) +{ + return nil == deadline ? cap : MIN(cap, deadline.timeIntervalSinceNow); +} + @interface FBSocks5TunnelManager () @property (nonatomic, nullable) NETunnelProviderManager *activeManager; /** Monotonic ms timestamp of the last consent tap dispatch; guards the re-attempt cooldown. */ @property (nonatomic) uint64_t lastConsentTapMs; +/** + Serializes the whole tunnel lifecycle. The socks5 routes are marked onControlQueue, and + FBWebServer only funnels the non-control ones, so requests arriving over different HTTP + connections reach this singleton concurrently on their own connection queues. Without this + queue a disconnect could return while an in-flight connect goes on to start the tunnel, and + two connects could race different proxy configurations through stop/save/reload/start. + */ +@property (nonatomic, strong) dispatch_queue_t lifecycleQueue; @end @implementation FBSocks5TunnelManager @@ -118,6 +140,28 @@ + (instancetype)sharedInstance return instance; } +- (instancetype)init +{ + self = [super init]; + if (nil != self) { + _lifecycleQueue = dispatch_queue_create("com.facebook.WebDriverAgent.socks5-lifecycle", + DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(_lifecycleQueue, FBSocks5LifecycleQueueKey, + (void *)FBSocks5LifecycleQueueKey, NULL); + } + return self; +} + +/// Runs `block` with the lifecycle queue held, tolerating a caller that already holds it. +- (void)performLocked:(NS_NOESCAPE dispatch_block_t)block +{ + if (NULL != dispatch_get_specific(FBSocks5LifecycleQueueKey)) { + block(); + return; + } + dispatch_sync(self.lifecycleQueue, block); +} + #pragma mark - Helpers - (NSString *)providerBundleIdentifier @@ -125,8 +169,15 @@ - (NSString *)providerBundleIdentifier return [NSBundle.mainBundle.bundleIdentifier stringByAppendingString:FBSocks5TunnelBundleSuffix]; } -- (BOOL)loadAllManagers:(NSArray **)outManagers error:(NSError **)error +- (BOOL)loadAllManagers:(NSArray **)outManagers + deadline:(nullable NSDate *)deadline + error:(NSError **)error { + NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout); + if (budget <= 0) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Ran out of time before loading the VPN preferences"); + } __block NSArray *managers = nil; __block NSError *loadError = nil; __block BOOL done = NO; @@ -135,7 +186,7 @@ - (BOOL)loadAllManagers:(NSArray **)outManagers error loadError = err; done = YES; }]; - [[[[FBRunLoopSpinner new] timeout:FBSocks5PreferencesTimeout] interval:0.05] spinUntilTrue:^BOOL{ + [[[[FBRunLoopSpinner new] timeout:budget] interval:0.05] spinUntilTrue:^BOOL{ return done; }]; if (!done || nil != loadError) { @@ -162,9 +213,13 @@ - (nullable NETunnelProviderManager *)ownManagerIn:(NSArray *buttons = alert.buttons.allElementsBoundByIndex; + // "Would Like to Add VPN Configurations" is Allow / Don't Allow. Anything with a different + // button count is a different prompt, whatever its labels say. + if (buttons.count != 2) { + continue; + } + for (XCUIElement *button in buttons) { + if ([button.label isEqualToString:label] && button.exists) { + return button; + } + } + } + return nil; +} + - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels { __block BOOL dispatched = NO; [FBWebServer performAutomationBlockOnMainQueue:^{ XCUIApplication *system = XCUIApplication.fb_systemApplication; for (NSString *label in labels) { - XCUIElement *button = system.buttons[label]; - if (!button.exists) { + XCUIElement *button = [self consentAlertButtonWithLabel:label]; + if (nil == button) { continue; } CGRect frame = button.frame; @@ -244,13 +327,32 @@ - (BOOL)connectWithURI:(FBSocks5URI *)uri timeout:(NSTimeInterval)timeout consentButtonLabels:(nullable NSArray *)consentButtonLabels error:(NSError **)error +{ + __block BOOL succeeded = NO; + __block NSError *localError = nil; + [self performLocked:^{ + succeeded = [self lockedConnectWithURI:uri + timeout:timeout + consentButtonLabels:consentButtonLabels + error:&localError]; + }]; + if (!succeeded && nil != error) { + *error = localError; + } + return succeeded; +} + +- (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error { NSTimeInterval budget = timeout > 0 ? timeout : FBSocks5DefaultConnectTimeout; NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:budget]; NSArray *labels = consentButtonLabels.count > 0 ? consentButtonLabels : @[@"Allow"]; NSArray *managers; - if (![self loadAllManagers:&managers error:error]) { + if (![self loadAllManagers:&managers deadline:deadline error:error]) { return NO; } NETunnelProviderManager *manager = [self ownManagerIn:managers] ?: [[NETunnelProviderManager alloc] init]; @@ -260,7 +362,7 @@ - (BOOL)connectWithURI:(FBSocks5URI *)uri if (status == NEVPNStatusConnected || status == NEVPNStatusConnecting || status == NEVPNStatusReasserting) { [FBLogger log:@"socks5/connect: stopping the already running tunnel first"]; [manager.connection stopVPNTunnel]; - if (![self waitUntilStopped:manager]) { + if (![self waitUntilStopped:manager deadline:deadline]) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, @"Timed out stopping the previously running SOCKS5 tunnel"); } @@ -285,7 +387,9 @@ - (BOOL)connectWithURI:(FBSocks5URI *)uri // system, so 'we dispatched one' is not evidence that it landed. tapConsentButtonWithLabels: // paces the re-attempts itself and answers NO once the alert is gone. __block BOOL consentTapped = NO; - [[[[FBRunLoopSpinner new] timeout:MAX(deadline.timeIntervalSinceNow, 1.0)] interval:0.3] spinUntilTrue:^BOOL{ + // No MAX(..., 1.0) floor here: granting an already-exhausted request another second is + // exactly the overshoot the caller's timeout is supposed to prevent. + [[[[FBRunLoopSpinner new] timeout:deadline.timeIntervalSinceNow] interval:0.3] spinUntilTrue:^BOOL{ if (saveDone) { return YES; } @@ -315,7 +419,7 @@ - (BOOL)connectWithURI:(FBSocks5URI *)uri reloadError = err; reloadDone = YES; }]; - [[[[FBRunLoopSpinner new] timeout:FBSocks5PreferencesTimeout] interval:0.05] spinUntilTrue:^BOOL{ + [[[[FBRunLoopSpinner new] timeout:FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout)] interval:0.05] spinUntilTrue:^BOOL{ return reloadDone; }]; if (!reloadDone || nil != reloadError) { @@ -330,17 +434,40 @@ - (BOOL)connectWithURI:(FBSocks5URI *)uri [NSString stringWithFormat:@"Cannot start the SOCKS5 tunnel: %@", startError.localizedDescription]); } - BOOL connected = [[[[FBRunLoopSpinner new] timeout:MAX(deadline.timeIntervalSinceNow, 1.0)] interval:0.2] spinUntilTrue:^BOOL{ - return manager.connection.status == NEVPNStatusConnected; + // The provider now validates the proxy before it reports startup, so a rejected start comes + // back as the session dropping to disconnected. Stop on that rather than spinning out the + // caller's whole deadline for a tunnel that is never going to come up. + __block BOOL startRejected = NO; + __block BOOL leftIdle = NO; + BOOL connected = [[[[FBRunLoopSpinner new] timeout:deadline.timeIntervalSinceNow] interval:0.2] spinUntilTrue:^BOOL{ + NEVPNStatus status = manager.connection.status; + if (status == NEVPNStatusConnected) { + return YES; + } + // The session can still read as disconnected for an instant after startVPNTunnel, so only + // treat that as terminal once it has actually entered a starting state. Reasserting still + // counts as in-flight; only a settled stop is terminal. + if (status != NEVPNStatusDisconnected && status != NEVPNStatusInvalid) { + leftIdle = YES; + } else if (leftIdle) { + startRejected = YES; + return YES; + } + return NO; }]; - if (!connected) { + if (!connected || startRejected) { NEVPNStatus finalStatus = manager.connection.status; [manager.connection stopVPNTunnel]; return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, - [NSString stringWithFormat: - @"The SOCKS5 tunnel did not connect within %.0fs (status %ld). " - "Check that the proxy at %@:%lu is reachable from the device", - budget, (long)finalStatus, uri.host, (unsigned long)uri.port]); + startRejected + ? [NSString stringWithFormat: + @"The SOCKS5 tunnel stopped right after starting. The proxy at %@:%lu " + "is unreachable, is not a SOCKS5 proxy, or rejected the credentials", + uri.host, (unsigned long)uri.port] + : [NSString stringWithFormat: + @"The SOCKS5 tunnel did not connect within %.0fs (status %ld). " + "Check that the proxy at %@:%lu is reachable from the device", + budget, (long)finalStatus, uri.host, (unsigned long)uri.port]); } self.activeManager = manager; [FBLogger logFmt:@"socks5/connect: tunnel connected through %@:%lu", uri.host, (unsigned long)uri.port]; @@ -348,9 +475,22 @@ - (BOOL)connectWithURI:(FBSocks5URI *)uri } - (BOOL)disconnectWithError:(NSError **)error +{ + __block BOOL succeeded = NO; + __block NSError *localError = nil; + [self performLocked:^{ + succeeded = [self lockedDisconnectWithError:&localError]; + }]; + if (!succeeded && nil != error) { + *error = localError; + } + return succeeded; +} + +- (BOOL)lockedDisconnectWithError:(NSError **)error { NSArray *managers; - if (![self loadAllManagers:&managers error:error]) { + if (![self loadAllManagers:&managers deadline:nil error:error]) { return NO; } NETunnelProviderManager *manager = [self ownManagerIn:managers] ?: self.activeManager; @@ -363,7 +503,7 @@ - (BOOL)disconnectWithError:(NSError **)error return YES; } [manager.connection stopVPNTunnel]; - if (![self waitUntilStopped:manager]) { + if (![self waitUntilStopped:manager deadline:nil]) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, @"Timed out stopping the SOCKS5 tunnel"); } @@ -371,14 +511,25 @@ - (BOOL)disconnectWithError:(NSError **)error return YES; } +// Serialized like the mutating operations so a caller never observes the tunnel halfway through +// a stop/save/reload/start sequence. - (NSDictionary *)statsDictionary +{ + __block NSDictionary *snapshot = nil; + [self performLocked:^{ + snapshot = [self lockedStatsDictionary]; + }]; + return snapshot; +} + +- (NSDictionary *)lockedStatsDictionary { NSMutableDictionary *stats = FBSocks5DisconnectedStats(); NETunnelProviderManager *manager = self.activeManager; if (nil == manager) { // Adopt a tunnel that survived a WDA restart (the configuration persists per install). NSArray *managers; - if ([self loadAllManagers:&managers error:nil]) { + if ([self loadAllManagers:&managers deadline:nil error:nil]) { manager = [self ownManagerIn:managers]; self.activeManager = manager; } diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.h b/WebDriverAgentTunnel/FBTunnelHevRunner.h index bd2608f27c..8c98732230 100644 --- a/WebDriverAgentTunnel/FBTunnelHevRunner.h +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.h @@ -26,7 +26,9 @@ NS_ASSUME_NONNULL_BEGIN @param configYAML the hev-socks5-tunnel YAML config @param tunFd the utun file descriptor the engine reads/writes packets on */ -- (void)startWithConfigYAML:(NSString *)configYAML tunFd:(int)tunFd; +- (void)startWithConfigYAML:(NSString *)configYAML + tunFd:(int)tunFd + exitHandler:(nullable void (^)(int exitCode))exitHandler; /** Asks the engine to quit and waits for the engine thread to exit. diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.m b/WebDriverAgentTunnel/FBTunnelHevRunner.m index 73e4cffaf5..c48b346312 100644 --- a/WebDriverAgentTunnel/FBTunnelHevRunner.m +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.m @@ -17,7 +17,9 @@ @interface FBTunnelHevRunner () @implementation FBTunnelHevRunner -- (void)startWithConfigYAML:(NSString *)configYAML tunFd:(int)tunFd +- (void)startWithConfigYAML:(NSString *)configYAML + tunFd:(int)tunFd + exitHandler:(nullable void (^)(int exitCode))exitHandler { if (self.isRunning) { return; @@ -33,6 +35,12 @@ - (void)startWithConfigYAML:(NSString *)configYAML tunFd:(int)tunFd NSLog(@"WebDriverAgentTunnel: hev-socks5-tunnel exited with code %d", code); weakSelf.isRunning = NO; dispatch_semaphore_signal(exitSemaphore); + // Reported even for a clean stop; the provider tells the two apart by whether it asked + // the engine to quit. An engine that exits on its own leaves the tunnel blackholed, so + // the provider must hear about it rather than keep advertising a working tunnel. + if (nil != exitHandler) { + exitHandler(code); + } }]; thread.name = @"hev-socks5-tunnel"; thread.qualityOfService = NSQualityOfServiceUserInitiated; diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m index 64fed9616f..028b1cefc9 100644 --- a/WebDriverAgentTunnel/FBTunnelPacketProvider.m +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -60,8 +60,150 @@ return ipv4 ?: ipv6; } +/// How long the pre-flight SOCKS5 handshake may take before the proxy counts as unreachable. +static const NSTimeInterval FBTunnelProbeTimeout = 8.0; +/// Grace period for the engine to fail its own initialization before startup is declared good. +static const NSTimeInterval FBTunnelEngineSettleTimeout = 0.75; + +static BOOL FBTunnelSetSocketTimeout(int fd, NSTimeInterval seconds) +{ + struct timeval tv; + tv.tv_sec = (time_t)seconds; + tv.tv_usec = (suseconds_t)((seconds - (NSTimeInterval)tv.tv_sec) * 1000000); + return 0 == setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) + && 0 == setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +} + +static BOOL FBTunnelReadFully(int fd, uint8_t *buffer, size_t length) +{ + size_t got = 0; + while (got < length) { + ssize_t n = recv(fd, buffer + got, length - got, 0); + if (n <= 0) { + return NO; + } + got += (size_t)n; + } + return YES; +} + +/** + Performs a SOCKS5 greeting (and username/password sub-negotiation when credentials are + configured) against the proxy, then closes the connection. + + hev only dials the proxy once tunneled traffic creates a session, so without this the provider + would report success for a proxy that is unreachable or rejects the credentials, and every + packet routed into the tunnel would be silently blackholed. Runs before the tunnel's network + settings are applied, so it cannot be captured by the tunnel it is validating. + */ +static NSError *_Nullable FBTunnelProbeSocks5(NSString *proxyIP, BOOL isIPv6, uint16_t port, + NSString *_Nullable user, NSString *_Nullable pass) +{ + int fd = socket(isIPv6 ? AF_INET6 : AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return FBTunnelError(@"Cannot create a socket to probe the SOCKS5 proxy"); + } + FBTunnelSetSocketTimeout(fd, FBTunnelProbeTimeout); + + int connected = -1; + if (isIPv6) { + struct sockaddr_in6 addr; + memset(&addr, 0, sizeof(addr)); + addr.sin6_family = AF_INET6; + addr.sin6_port = htons(port); + if (1 != inet_pton(AF_INET6, proxyIP.UTF8String, &addr.sin6_addr)) { + close(fd); + return FBTunnelError(@"Cannot parse the resolved SOCKS5 proxy address"); + } + connected = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + } else { + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (1 != inet_pton(AF_INET, proxyIP.UTF8String, &addr.sin_addr)) { + close(fd); + return FBTunnelError(@"Cannot parse the resolved SOCKS5 proxy address"); + } + connected = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + } + if (0 != connected) { + int err = errno; + close(fd); + return FBTunnelError([NSString stringWithFormat: + @"Cannot reach the SOCKS5 proxy at %@:%u: %s", proxyIP, port, strerror(err)]); + } + + BOOL hasCredentials = user.length > 0; + uint8_t greeting[4]; + size_t greetingLength = 0; + greeting[greetingLength++] = 0x05; + greeting[greetingLength++] = hasCredentials ? 2 : 1; + greeting[greetingLength++] = 0x00; // no authentication + if (hasCredentials) { + greeting[greetingLength++] = 0x02; // username/password + } + if (send(fd, greeting, greetingLength, 0) != (ssize_t)greetingLength) { + close(fd); + return FBTunnelError(@"The SOCKS5 proxy closed the connection during the greeting"); + } + + uint8_t choice[2] = {0}; + if (!FBTunnelReadFully(fd, choice, sizeof(choice))) { + close(fd); + return FBTunnelError(@"The SOCKS5 proxy did not answer the greeting"); + } + if (0x05 != choice[0]) { + close(fd); + return FBTunnelError([NSString stringWithFormat: + @"The server at %@:%u is not a SOCKS5 proxy", proxyIP, port]); + } + if (0xFF == choice[1]) { + close(fd); + return FBTunnelError(hasCredentials + ? @"The SOCKS5 proxy rejected both supported authentication methods" + : @"The SOCKS5 proxy requires authentication but no credentials were configured"); + } + if (0x02 == choice[1]) { + NSData *userData = [user dataUsingEncoding:NSUTF8StringEncoding]; + NSData *passData = [(pass ?: @"") dataUsingEncoding:NSUTF8StringEncoding]; + if (userData.length > 255 || passData.length > 255) { + close(fd); + return FBTunnelError(@"The SOCKS5 credentials exceed the 255 byte protocol limit"); + } + NSMutableData *auth = [NSMutableData dataWithBytes:(uint8_t[]){0x01} length:1]; + uint8_t userLength = (uint8_t)userData.length; + [auth appendBytes:&userLength length:1]; + [auth appendData:userData]; + uint8_t passLength = (uint8_t)passData.length; + [auth appendBytes:&passLength length:1]; + [auth appendData:passData]; + if (send(fd, auth.bytes, auth.length, 0) != (ssize_t)auth.length) { + close(fd); + return FBTunnelError(@"The SOCKS5 proxy closed the connection during authentication"); + } + uint8_t authReply[2] = {0}; + if (!FBTunnelReadFully(fd, authReply, sizeof(authReply))) { + close(fd); + return FBTunnelError(@"The SOCKS5 proxy did not answer the authentication request"); + } + if (0x00 != authReply[1]) { + close(fd); + return FBTunnelError(@"The SOCKS5 proxy rejected the configured credentials"); + } + } else if (0x00 != choice[1]) { + close(fd); + return FBTunnelError([NSString stringWithFormat: + @"The SOCKS5 proxy selected unsupported authentication method 0x%02x", choice[1]]); + } + close(fd); + return nil; +} + @interface FBTunnelPacketProvider () @property (nonatomic, nullable) FBTunnelHevRunner *runner; +/** Set once a stop was requested, so the engine's exit is not mistaken for a crash. */ +@property (atomic) BOOL isStopping; @end @implementation FBTunnelPacketProvider @@ -90,6 +232,16 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) NSLog(@"WebDriverAgentTunnel: starting tunnel through %@:%@ (resolved %@, remoteDNS=%d)", host, port, proxyIP, remoteDNS); + // Fail before any routes are installed, so an unreachable proxy or bad credentials surface as + // a start error instead of a "connected" tunnel that drops every packet. + NSError *probeError = FBTunnelProbeSocks5(proxyIP, proxyIsIPv6, (uint16_t)port.unsignedIntValue, + config[FBSocks5KeyUser], config[FBSocks5KeyPass]); + if (nil != probeError) { + NSLog(@"WebDriverAgentTunnel: proxy pre-flight failed: %@", probeError.localizedDescription); + completionHandler(probeError); + return; + } + NEPacketTunnelNetworkSettings *settings = [[NEPacketTunnelNetworkSettings alloc] initWithTunnelRemoteAddress:proxyIP]; NEIPv4Settings *ipv4 = [[NEIPv4Settings alloc] initWithAddresses:@[FBSocks5TunnelIPv4Address] @@ -133,8 +285,33 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) NSMutableDictionary *engineConfig = [config mutableCopy]; engineConfig[FBSocks5KeyHost] = proxyIP; NSString *yaml = FBSocks5HevConfigFromProviderConfiguration(engineConfig); - strongSelf.runner = [[FBTunnelHevRunner alloc] init]; - [strongSelf.runner startWithConfigYAML:yaml tunFd:tunFd]; + FBTunnelHevRunner *runner = [[FBTunnelHevRunner alloc] init]; + strongSelf.runner = runner; + // An engine that fails to initialize returns from its main almost immediately. Report that + // as a start failure rather than letting NetworkExtension reach NEVPNStatusConnected; once + // startup has been acknowledged, an unexpected exit tears the tunnel down instead of leaving + // it advertised but blackholed. + dispatch_semaphore_t settled = dispatch_semaphore_create(0); + __block BOOL startupAcknowledged = NO; + [runner startWithConfigYAML:yaml tunFd:tunFd exitHandler:^(int exitCode) { + __strong typeof(weakSelf) exitSelf = weakSelf; + dispatch_semaphore_signal(settled); + if (!startupAcknowledged || nil == exitSelf || exitSelf.isStopping) { + return; + } + NSLog(@"WebDriverAgentTunnel: engine exited unexpectedly (code %d); cancelling the tunnel", exitCode); + [exitSelf cancelTunnelWithError: + FBTunnelError([NSString stringWithFormat:@"The SOCKS5 engine exited unexpectedly with code %d", exitCode])]; + }]; + BOOL exitedEarly = 0 == dispatch_semaphore_wait(settled, + dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(FBTunnelEngineSettleTimeout * NSEC_PER_SEC))); + if (exitedEarly || !runner.isRunning) { + strongSelf.runner = nil; + completionHandler(FBTunnelError(@"The SOCKS5 engine failed to start; check the proxy configuration")); + return; + } + startupAcknowledged = YES; completionHandler(nil); }]; } @@ -143,6 +320,7 @@ - (void)stopTunnelWithReason:(NEProviderStopReason)reason completionHandler:(void (^)(void))completionHandler { NSLog(@"WebDriverAgentTunnel: stopping tunnel (reason %ld)", (long)reason); + self.isStopping = YES; [self.runner stopAndWait:5.0]; self.runner = nil; completionHandler(); From d53fbc65d2e3000583c028828869eaea4f83a728 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:15:17 +0200 Subject: [PATCH 13/23] fix: address the second review round of the socks5 tunnel Build the engine from the normal Appium launch path. The tunnel target links a gitignored xcframework, so a fresh clone or npm install fails immediately with "There is no XCFramework found"; Scripts/build.sh only covers the CI and bundling flows. This cannot be fixed inside the Xcode project - xcodebuild resolves XCFramework references while computing the build graph, so neither a run-script phase on the target nor an aggregate target it depends on ever runs (both were tried and still failed). Build it from XcodeBuild.start instead, which is the path that was reported. Stop IPv6 from bypassing the tunnel. Only IPv4 routes were installed, so on a dual-stack device every AAAA-reachable destination kept using the real egress while the tunnel was up. The provider now claims IPv6 as well, with a /128 exclusion for an IPv6 proxy. hev has no IPv6 path, so such traffic is dropped rather than leaked - failing closed, at the cost of IPv6-only destinations being unreachable while connected. Verified that normal browsing still works, falling back to IPv4 through the tunnel. Identify the consent alert by more than its button count. Two buttons plus an "Allow" label also describes other system permission prompts, so the matcher could grant an unrelated permission. It now anchors on the alert's own text as well. Make the startup acknowledgement atomic. An engine exiting between the settle wait and the flag being set left the exit callback suppressing cancellation while startup reported success, advertising a connected tunnel over a dead engine. Both paths now decide under one lock. Let a disconnecting tunnel settle before reconfiguring it. An immediate retry after a timed-out connect, or a connect racing an external VPN stop, would rewrite and start the manager while its previous stop was in flight. Disable SIGPIPE on the preflight socket. A proxy closing the connection mid-handshake raised SIGPIPE on Darwin and terminated the extension: the engine's process-wide ignore has not been installed during the preflight. Co-Authored-By: Claude Fable 5 --- .../Utilities/FBSocks5TunnelManager.m | 29 +++++++--- .../Utilities/FBSocks5TunnelProtocol.h | 3 ++ .../Utilities/FBSocks5TunnelProtocol.m | 3 ++ WebDriverAgentTunnel/FBTunnelPacketProvider.m | 53 ++++++++++++++++--- lib/xcodebuild.ts | 38 ++++++++++++- 5 files changed, 112 insertions(+), 14 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 22a24ec468..bfa4bb2429 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -262,11 +262,20 @@ - (nullable XCUIElement *)consentAlertButtonWithLabel:(NSString *)label continue; } NSArray *buttons = alert.buttons.allElementsBoundByIndex; - // "Would Like to Add VPN Configurations" is Allow / Don't Allow. Anything with a different - // button count is a different prompt, whatever its labels say. + // "Would Like to Add VPN Configurations" is Allow / Don't Allow. A different button count is + // a different prompt, whatever its labels say. if (buttons.count != 2) { continue; } + // Two buttons plus a common label is not an identity: other system permission prompts are + // also Allow / Don't Allow, and granting one of those instead would hand out an unrelated + // permission. Anchor on the alert's own text as well. "VPN" is an initialism Apple leaves + // untranslated in this title across locales; if that ever stops holding, the alert simply is + // not matched and connect fails on the save timeout, which is the safe direction to fail. + NSString *identity = [NSString stringWithFormat:@"%@ %@", alert.identifier ?: @"", alert.label ?: @""]; + if ([identity rangeOfString:@"VPN" options:NSCaseInsensitiveSearch].location == NSNotFound) { + continue; + } for (XCUIElement *button in buttons) { if ([button.label isEqualToString:label] && button.exists) { return button; @@ -357,11 +366,19 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri } NETunnelProviderManager *manager = [self ownManagerIn:managers] ?: [[NETunnelProviderManager alloc] init]; - // Connecting while a tunnel runs replaces it. + // Connecting while a tunnel runs replaces it. Disconnecting counts as in-flight too: an + // immediate retry after a timed-out connect, or a connect racing an external VPN stop, would + // otherwise rewrite, save and start this very manager while its previous stop is still + // running - and that stop then rejects or tears down the replacement. NEVPNStatus status = manager.connection.status; - if (status == NEVPNStatusConnected || status == NEVPNStatusConnecting || status == NEVPNStatusReasserting) { - [FBLogger log:@"socks5/connect: stopping the already running tunnel first"]; - [manager.connection stopVPNTunnel]; + if (status == NEVPNStatusConnected || status == NEVPNStatusConnecting + || status == NEVPNStatusReasserting || status == NEVPNStatusDisconnecting) { + if (status == NEVPNStatusDisconnecting) { + [FBLogger log:@"socks5/connect: waiting for the in-flight tunnel stop to settle first"]; + } else { + [FBLogger log:@"socks5/connect: stopping the already running tunnel first"]; + [manager.connection stopVPNTunnel]; + } if (![self waitUntilStopped:manager deadline:deadline]) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, @"Timed out stopping the previously running SOCKS5 tunnel"); diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h index aa9a613f0e..af74d791e9 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h @@ -51,6 +51,9 @@ extern const NSUInteger FBSocks5DefaultPort; hev config, so both come from these constants. */ extern NSString *const FBSocks5TunnelIPv4Address; extern NSString *const FBSocks5TunnelIPv4Netmask; +/** Link-local-ish ULA the tunnel claims so IPv6 cannot bypass it; see FBTunnelPacketProvider. */ +extern NSString *const FBSocks5TunnelIPv6Address; +extern const NSUInteger FBSocks5TunnelIPv6PrefixLength; /** Synthetic resolver address hev's mapdns listens on (socks5h mode); used as the tunnel's DNS server so queries are hijacked into hostname-preserving CONNECTs. */ extern NSString *const FBSocks5TunnelMapDNSAddress; diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m index 34774e5f58..6500e3c594 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m @@ -29,6 +29,9 @@ NSString *const FBSocks5TunnelIPv4Address = @"198.18.0.1"; NSString *const FBSocks5TunnelIPv4Netmask = @"255.255.255.0"; +// ULA (fc00::/7) so it cannot collide with a real global address on the device. +NSString *const FBSocks5TunnelIPv6Address = @"fd6d:6f62:696c::1"; +const NSUInteger FBSocks5TunnelIPv6PrefixLength = 64; NSString *const FBSocks5TunnelMapDNSAddress = @"198.18.0.2"; const NSUInteger FBSocks5TunnelMTU = 8500; diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m index 028b1cefc9..6b8b49a0c7 100644 --- a/WebDriverAgentTunnel/FBTunnelPacketProvider.m +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -103,6 +103,11 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia if (fd < 0) { return FBTunnelError(@"Cannot create a socket to probe the SOCKS5 proxy"); } + // A proxy that closes the connection mid-handshake makes send() raise SIGPIPE on Darwin, + // which would terminate the extension outright. The engine installs a process-wide SIGPIPE + // ignore, but that happens later - this probe runs before it, so opt out per socket. + int noSigPipe = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)); FBTunnelSetSocketTimeout(fd, FBTunnelProbeTimeout); int connected = -1; @@ -249,11 +254,27 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) ipv4.includedRoutes = @[NEIPv4Route.defaultRoute]; if (!proxyIsIPv6) { // The engine's own TCP connection to the proxy must not loop back into the tunnel. - // (An IPv6 proxy needs no exclusion: the tunnel only captures IPv4.) ipv4.excludedRoutes = @[[[NEIPv4Route alloc] initWithDestinationAddress:proxyIP subnetMask:@"255.255.255.255"]]; } settings.IPv4Settings = ipv4; + + // Claim IPv6 as well, even though the engine only speaks IPv4. Leaving IPv6 unclaimed is not + // neutral: on a dual-stack device every AAAA-reachable destination would keep using the real + // egress while the tunnel was up, quietly defeating the point of routing through the proxy. + // Capturing it without an IPv6 path in hev means such traffic is dropped rather than leaked - + // deliberately failing closed. Connections then fall back to IPv4 through the tunnel; a + // genuinely IPv6-only destination becomes unreachable while connected, which is the trade + // this makes. Giving hev a real IPv6 interface would lift that and is the follow-up. + NEIPv6Settings *ipv6 = [[NEIPv6Settings alloc] initWithAddresses:@[FBSocks5TunnelIPv6Address] + networkPrefixLengths:@[@(FBSocks5TunnelIPv6PrefixLength)]]; + ipv6.includedRoutes = @[NEIPv6Route.defaultRoute]; + if (proxyIsIPv6) { + // Same reasoning as the IPv4 exclusion: keep the engine's own dial out of its own tunnel. + ipv6.excludedRoutes = @[[[NEIPv6Route alloc] initWithDestinationAddress:proxyIP + networkPrefixLength:@128]]; + } + settings.IPv6Settings = ipv6; // socks5h: point DNS at hev's mapdns so queries become hostname-preserving CONNECTs. // socks5: use public resolvers; the queries travel through the tunnel via the proxy's // UDP relay (requires a proxy with UDP ASSOCIATE support). @@ -291,27 +312,45 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) // as a start failure rather than letting NetworkExtension reach NEVPNStatusConnected; once // startup has been acknowledged, an unexpected exit tears the tunnel down instead of leaving // it advertised but blackholed. + // Acknowledgement and exit have to agree under one lock. Checking a plain flag leaves a + // window where the engine dies after the settle wait but before the flag is set: the exit + // callback reads NO and suppresses cancellation, and startup then reports success for a dead + // engine. Under the lock exactly one of the two paths wins - either the exit is already + // recorded and startup fails, or startup is acknowledged and the exit cancels the tunnel. dispatch_semaphore_t settled = dispatch_semaphore_create(0); + NSObject *startupLock = [NSObject new]; __block BOOL startupAcknowledged = NO; + __block BOOL engineExited = NO; [runner startWithConfigYAML:yaml tunFd:tunFd exitHandler:^(int exitCode) { __strong typeof(weakSelf) exitSelf = weakSelf; + BOOL shouldCancel = NO; + @synchronized (startupLock) { + engineExited = YES; + shouldCancel = startupAcknowledged; + } dispatch_semaphore_signal(settled); - if (!startupAcknowledged || nil == exitSelf || exitSelf.isStopping) { + if (!shouldCancel || nil == exitSelf || exitSelf.isStopping) { return; } NSLog(@"WebDriverAgentTunnel: engine exited unexpectedly (code %d); cancelling the tunnel", exitCode); [exitSelf cancelTunnelWithError: FBTunnelError([NSString stringWithFormat:@"The SOCKS5 engine exited unexpectedly with code %d", exitCode])]; }]; - BOOL exitedEarly = 0 == dispatch_semaphore_wait(settled, - dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(FBTunnelEngineSettleTimeout * NSEC_PER_SEC))); - if (exitedEarly || !runner.isRunning) { + dispatch_semaphore_wait(settled, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(FBTunnelEngineSettleTimeout * NSEC_PER_SEC))); + BOOL startupFailed = NO; + @synchronized (startupLock) { + if (engineExited) { + startupFailed = YES; + } else { + startupAcknowledged = YES; + } + } + if (startupFailed) { strongSelf.runner = nil; completionHandler(FBTunnelError(@"The SOCKS5 engine failed to start; check the proxy configuration")); return; } - startupAcknowledged = YES; completionHandler(nil); }]; } diff --git a/lib/xcodebuild.ts b/lib/xcodebuild.ts index 63a1d02411..3bcb1aec21 100644 --- a/lib/xcodebuild.ts +++ b/lib/xcodebuild.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import {logger, timing, util} from '@appium/support'; +import {fs, logger, timing, util} from '@appium/support'; import type {AppiumLogger, StringRecord} from '@appium/types'; import {retryInterval} from 'asyncbox'; import {SubProcess, exec} from 'teen_process'; @@ -226,6 +226,41 @@ export class XcodeBuild { } } + /** + * Builds ThirdParty/HevSocks5Tunnel.xcframework if it is not there yet. + * + * The WebDriverAgentTunnel target links that xcframework, and it is a gitignored build + * product, so a fresh clone or an npm install does not have one. Scripts/build.sh prepares it + * for the CI and bundling flows, but this class invokes xcodebuild directly and would + * otherwise fail immediately with "There is no XCFramework found". It cannot be fixed inside + * the Xcode project: xcodebuild resolves XCFramework references while computing the build + * graph, before any build phase of the target - or of a target it depends on - gets to run. + * + * The script is a no-op once its stamp matches, so this only costs a process spawn on + * subsequent launches. A failure here is logged and swallowed rather than thrown: xcodebuild + * reports the missing framework with a clearer message, and installations that never touch + * the tunnel should not be blocked by an engine build. + */ + async prepareSocks5Engine(): Promise { + const projectRoot = path.dirname(this.agentPath); + const scriptPath = path.join(projectRoot, 'Scripts', 'build-hev-socks5-tunnel.sh'); + if (!(await fs.exists(scriptPath))) { + return; + } + if (await fs.exists(path.join(projectRoot, 'ThirdParty', 'HevSocks5Tunnel.xcframework'))) { + return; + } + this.log.info('Building the SOCKS5 tunnel engine, which the WebDriverAgentTunnel target links'); + try { + await exec(scriptPath, [], {cwd: projectRoot}); + } catch (e) { + this.log.warn( + `Cannot build the SOCKS5 tunnel engine: ${(e as Error).message}. ` + + `The WebDriverAgentTunnel target will not build until ${scriptPath} succeeds.` + ); + } + } + /** * Starts the xcodebuild process to build and/or test WebDriverAgent. * @param buildOnly - If `true`, only builds without running tests. Defaults to `false`. @@ -233,6 +268,7 @@ export class XcodeBuild { * @throws Error if xcodebuild fails or cannot start */ async start(buildOnly: boolean = false): Promise { + await this.prepareSocks5Engine(); this.xcodebuild = await this.createSubProcess(buildOnly); // wrap the start procedure in a promise so that we can catch, and report, From 4cad1023f1a39e23d746fd2c00eaccfc07641725 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:31:08 +0200 Subject: [PATCH 14/23] fix: serialize concurrent builds of the shared hev source tree build_static runs `make clean` in the shared submodule checkout, so two preparations racing from one checkout - two WDA sessions starting at once - could delete each other's archives mid-compile or while libtool was merging them, giving nondeterministic failures or a corrupt xcframework. Take a cross-process lock, and hold it across the stamp check as well as the build: the loser then re-reads the stamp the winner just wrote and exits early instead of rebuilding what already exists. The lock is a mkdir, which is atomic on every filesystem this runs on, and records its owner pid so a lock left behind by a killed build is reclaimed rather than blocking for the full timeout. Verified with two concurrent invocations (one builds, the other waits and then reports up-to-date) and with a stale lock naming a dead pid. Co-Authored-By: Claude Fable 5 --- Scripts/build-hev-socks5-tunnel.sh | 40 +++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/Scripts/build-hev-socks5-tunnel.sh b/Scripts/build-hev-socks5-tunnel.sh index bff5c17f44..8822f90bd2 100755 --- a/Scripts/build-hev-socks5-tunnel.sh +++ b/Scripts/build-hev-socks5-tunnel.sh @@ -39,6 +39,45 @@ fi SCRIPT_SHA=$(shasum -a 256 "$0" | cut -d' ' -f1) STAMP="${SOURCE_ID}-${SCRIPT_SHA}-${MIN_IOS}" +# build_static runs `make clean` in the shared submodule checkout, so two concurrent +# preparations (e.g. two WDA sessions starting at once) would delete each other's archives +# mid-compile or mid-libtool and produce nondeterministic failures or a corrupt xcframework. +# Serialize across processes, and hold the lock across the stamp check too: the loser then +# re-reads the stamp the winner just wrote and exits early instead of rebuilding. +LOCK_DIR="$ROOT_DIR/ThirdParty/.hev-socks5-tunnel.lock" +LOCK_WAIT_SECONDS=900 +LOCK_HELD=0 +BUILD_DIR="" + +cleanup() +{ + [ -n "$BUILD_DIR" ] && rm -rf "$BUILD_DIR" + [ "$LOCK_HELD" = "1" ] && rm -rf "$LOCK_DIR" + return 0 +} +trap cleanup EXIT + +# mkdir is atomic on every filesystem this runs on, which `[ -e ] && touch` is not. +waited=0 +while ! mkdir "$LOCK_DIR" 2>/dev/null; do + owner=$(cat "$LOCK_DIR/pid" 2>/dev/null || true) + if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then + echo "removing a stale hev-socks5-tunnel build lock left by pid $owner" >&2 + rm -rf "$LOCK_DIR" + continue + fi + if [ "$waited" -ge "$LOCK_WAIT_SECONDS" ]; then + echo "error: timed out after ${LOCK_WAIT_SECONDS}s waiting for $LOCK_DIR" >&2 + echo " remove it by hand if no other build is running" >&2 + exit 1 + fi + [ "$waited" = "0" ] && echo "another hev-socks5-tunnel build is running; waiting for it" + sleep 1 + waited=$((waited + 1)) +done +LOCK_HELD=1 +echo $$ > "$LOCK_DIR/pid" + if [ "${1:-}" != "--force" ] && [ -d "$OUTPUT" ] && [ -f "$STAMP_FILE" ] \ && [ "$(cat "$STAMP_FILE")" = "$STAMP" ]; then echo "HevSocks5Tunnel.xcframework is up to date ($SUBMODULE_SHA); skipping build" @@ -46,7 +85,6 @@ if [ "${1:-}" != "--force" ] && [ -d "$OUTPUT" ] && [ -f "$STAMP_FILE" ] \ fi BUILD_DIR=$(mktemp -d -t hev-socks5-tunnel-build) -trap 'rm -rf "$BUILD_DIR"' EXIT # Other-platform sources (linux/windows/jni/...) compile to empty objects on # iOS, producing harmless 'has no symbols' archive warnings; drop just those. From 2b207c5d5c188ad71fb5a3e0113b1c7adc3db6ea Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:36:35 +0200 Subject: [PATCH 15/23] fix: fence a timed-out VPN save before the next lifecycle operation Returning from a timed-out saveToPreferences does not cancel it, so NetworkExtension could still persist the manager afterwards. A follow-up operation running before that landed would load no manager, build a second one with the same provider id, and leave two persisted configurations. ownManagerIn: returned whichever came first, so disconnect could pick the disconnected duplicate and report success while the real tunnel kept running - the VPN stayed up with nothing left pointing at it. Two changes, because either alone still leaves a hole: - Record the in-flight save and have the next locked connect/disconnect wait for it to settle before loading. Blocking here is safe from the lifecycle queue: the completion is delivered on the main queue, which that queue does not occupy. The wait is bounded by the caller's own deadline and clears the fence either way, so one stuck save cannot block every later call. The timed-out branch deliberately leaves it armed; every path past it has the completion in hand and disarms it. - Make ownManagerIn: prefer a manager that is not idle. Duplicates can still appear transiently, and a stale one must never shadow a running tunnel when disconnect asks which manager is ours. Co-Authored-By: Claude Fable 5 --- .../Utilities/FBSocks5TunnelManager.m | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index bfa4bb2429..2d68918c0c 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -118,6 +118,16 @@ @interface FBSocks5TunnelManager () @property (nonatomic, nullable) NETunnelProviderManager *activeManager; /** Monotonic ms timestamp of the last consent tap dispatch; guards the re-attempt cooldown. */ @property (nonatomic) uint64_t lastConsentTapMs; +/** + Signalled when a saveToPreferences that outlived its request finally completes. + + Returning from a timed-out save does not cancel it - NetworkExtension can still persist the + manager afterwards. A follow-up operation that ran before that landed would load no manager, + build a second one with the same provider id, and end up with two persisted configurations; + ownManagerIn: could then hand a disconnected duplicate to disconnect, which would report + success while the real tunnel kept running. Non-nil means such a save is still outstanding. + */ +@property (atomic, nullable) dispatch_semaphore_t pendingSaveSignal; /** Serializes the whole tunnel lifecycle. The socks5 routes are marked onControlQueue, and FBWebServer only funnels the non-control ones, so requests arriving over different HTTP @@ -152,6 +162,26 @@ - (instancetype)init return self; } +/** + Blocks until a save left in flight by a timed-out connect settles, so the next operation sees + whatever it persisted instead of racing it. Safe to call from the lifecycle queue: the + completion is delivered on the main queue, which this queue does not occupy. + */ +- (void)fencePendingSaveWithDeadline:(nullable NSDate *)deadline +{ + dispatch_semaphore_t signal = self.pendingSaveSignal; + if (nil == signal) { + return; + } + NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout); + if (budget > 0) { + [FBLogger log:@"socks5: waiting for a previously timed-out VPN save to settle"]; + dispatch_semaphore_wait(signal, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(budget * NSEC_PER_SEC))); + } + // Cleared either way: a save that outlives even this is not worth blocking every later call on. + self.pendingSaveSignal = nil; +} + /// Runs `block` with the lifecycle queue held, tolerating a caller that already holds it. - (void)performLocked:(NS_NOESCAPE dispatch_block_t)block { @@ -200,17 +230,29 @@ - (BOOL)loadAllManagers:(NSArray **)outManagers return YES; } +// Returns the manager for our own provider, preferring one that is not idle. Duplicates with the +// same provider id can exist transiently (see pendingSaveSignal); picking whichever came first +// would let a disconnected duplicate shadow the running tunnel, so disconnect would report +// success without stopping anything. - (nullable NETunnelProviderManager *)ownManagerIn:(NSArray *)managers { NSString *providerId = self.providerBundleIdentifier; + NETunnelProviderManager *idleMatch = nil; for (NETunnelProviderManager *manager in managers) { NETunnelProviderProtocol *protocol = (NETunnelProviderProtocol *)manager.protocolConfiguration; - if ([protocol isKindOfClass:NETunnelProviderProtocol.class] - && [protocol.providerBundleIdentifier isEqualToString:providerId]) { + if (![protocol isKindOfClass:NETunnelProviderProtocol.class] + || ![protocol.providerBundleIdentifier isEqualToString:providerId]) { + continue; + } + NEVPNStatus status = manager.connection.status; + if (status != NEVPNStatusDisconnected && status != NEVPNStatusInvalid) { return manager; } + if (nil == idleMatch) { + idleMatch = manager; + } } - return nil; + return idleMatch; } - (BOOL)waitUntilStopped:(NETunnelProviderManager *)manager deadline:(nullable NSDate *)deadline @@ -360,6 +402,7 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:budget]; NSArray *labels = consentButtonLabels.count > 0 ? consentButtonLabels : @[@"Allow"]; + [self fencePendingSaveWithDeadline:deadline]; NSArray *managers; if (![self loadAllManagers:&managers deadline:deadline error:error]) { return NO; @@ -396,9 +439,12 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri __block BOOL saveDone = NO; __block NSError *saveError = nil; + dispatch_semaphore_t saveSignal = dispatch_semaphore_create(0); + self.pendingSaveSignal = saveSignal; [manager saveToPreferencesWithCompletionHandler:^(NSError *err) { saveError = err; saveDone = YES; + dispatch_semaphore_signal(saveSignal); }]; // Keep re-attempting for as long as the alert is still up: a dispatched tap can be shed by the // system, so 'we dispatched one' is not evidence that it landed. tapConsentButtonWithLabels: @@ -423,6 +469,7 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri "devices with a passcode cannot confirm the VPN consent automatically", consentTapped ? @"confirmed" : @"not confirmed"]); } + self.pendingSaveSignal = nil; if (nil != saveError) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorNotAuthorized, [NSString stringWithFormat:@"The VPN configuration was not authorized: %@", @@ -506,6 +553,9 @@ - (BOOL)disconnectWithError:(NSError **)error - (BOOL)lockedDisconnectWithError:(NSError **)error { + // Without this a disconnect racing a timed-out connect could load nothing, or load a duplicate + // that is not the tunnel actually running, and report success while the VPN stayed up. + [self fencePendingSaveWithDeadline:nil]; NSArray *managers; if (![self loadAllManagers:&managers deadline:nil error:error]) { return NO; From 65e1825ed9e7eeff3ef90c5fabbf3c91b96bb41c Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:57:29 +0200 Subject: [PATCH 16/23] fix: close six correctness gaps around the socks5 lifecycle and build Start the timeout before queueing. The deadline was created inside the locked body, so time spent waiting for another operation to release the lifecycle queue did not count: a connect with timeout:1 could sit behind a 30s connect and then be handed a fresh one-second budget. The wrapper now stamps the deadline before enqueueing and passes it in. Keep the mutation and its reported stats in one transaction. connect and disconnect released the lifecycle queue before the endpoint asked for stats separately, so an overlapping operation ran in between and a successful connect could answer connected:false, or a disconnect could answer with a newly connected tunnel. Both now return the snapshot taken while they still held the lock. Verified with overlapping requests: each response describes its own transaction. Mirror hev's authentication method selection in the preflight. hev_socks5_client_write_auth_methods always offers exactly one method - username/password only when BOTH fields are set, no-auth otherwise - while the probe offered both whenever a user was present. A no-auth-only proxy with credentials, or a user-without-password URI, therefore passed preflight and reported connected:true even though every real session is rejected. Bound the quit request itself. hev_socks5_tunnel_quit() can block waiting for event_fds[1] when the engine has not initialized yet or is exiting concurrently, and the semaphore timeout was only evaluated afterwards, so stopAndWait: did not actually bound that path and the NetworkExtension stop callback could hang forever. The quit is now issued off the caller's thread so the deadline covers the request as well as the exit. Let the stamp decide whether the engine needs rebuilding. Skipping the script whenever the xcframework directory merely existed meant a stale output - from an older checkout, a submodule bump, a script revision, or a failed partial build - was linked indefinitely. The script already exits cheaply when its SHA stamp is current. Reclaim stale build locks without deleting a live owner. Two builders could read the same dead pid and both rm -rf, so the slower one removed the lock the faster one had just acquired, putting both inside the shared make clean/build. Reclamation now claims the directory by rename - atomic, so exactly one builder wins - and re-confirms the pid before deleting. Verified with three concurrent builders against one stale lock: exactly one reclaimed it, one built, the others waited and saw the fresh stamp. Co-Authored-By: Claude Fable 5 --- Scripts/build-hev-socks5-tunnel.sh | 18 ++++- .../Commands/FBMobilerunSocks5Commands.m | 20 ++++-- .../Utilities/FBSocks5TunnelManager.h | 17 +++-- .../Utilities/FBSocks5TunnelManager.m | 68 +++++++++++-------- WebDriverAgentTunnel/FBTunnelHevRunner.m | 9 ++- WebDriverAgentTunnel/FBTunnelPacketProvider.m | 26 +++---- lib/xcodebuild.ts | 9 +-- 7 files changed, 106 insertions(+), 61 deletions(-) diff --git a/Scripts/build-hev-socks5-tunnel.sh b/Scripts/build-hev-socks5-tunnel.sh index 8822f90bd2..d5486e7fe8 100755 --- a/Scripts/build-hev-socks5-tunnel.sh +++ b/Scripts/build-hev-socks5-tunnel.sh @@ -62,8 +62,22 @@ waited=0 while ! mkdir "$LOCK_DIR" 2>/dev/null; do owner=$(cat "$LOCK_DIR/pid" 2>/dev/null || true) if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then - echo "removing a stale hev-socks5-tunnel build lock left by pid $owner" >&2 - rm -rf "$LOCK_DIR" + # Do NOT rm -rf the observation directly: two builders can read the same dead pid, and + # the slower one would then delete the lock the faster one has already re-acquired, + # putting both inside the shared make clean/build this lock exists to prevent. Claim it + # by renaming instead - mv of a directory onto a non-existent name is atomic, so exactly + # one builder can succeed - and only delete what we ourselves took, after re-confirming + # the pid inside is still the dead one we saw. + claimed="$LOCK_DIR.stale.$$" + if mv "$LOCK_DIR" "$claimed" 2>/dev/null; then + if [ "$(cat "$claimed/pid" 2>/dev/null || true)" = "$owner" ]; then + echo "reclaimed a stale hev-socks5-tunnel build lock left by pid $owner" >&2 + rm -rf "$claimed" + else + # Somebody re-created it between our read and our rename; hand it back untouched. + mv "$claimed" "$LOCK_DIR" 2>/dev/null || rm -rf "$claimed" + fi + fi continue fi if [ "$waited" -ge "$LOCK_WAIT_SECONDS" ]; then diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m index 22064d2dd0..af89defa02 100644 --- a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m @@ -64,22 +64,28 @@ + (NSArray *)routes } NSError *error; - if (![FBSocks5TunnelManager.sharedInstance connectWithURI:uri - timeout:timeout - consentButtonLabels:consentLabels - error:&error]) { + // The snapshot comes back from inside the same lifecycle transaction as the mutation; asking + // for stats afterwards would let a queued disconnect slip in and make this response describe + // a tunnel that is already gone. + NSDictionary *stats = + [FBSocks5TunnelManager.sharedInstance connectWithURI:uri + timeout:timeout + consentButtonLabels:consentLabels + error:&error]; + if (nil == stats) { return [self responseWithTunnelManagerError:error]; } - return FBResponseWithObject(FBSocks5TunnelManager.sharedInstance.statsDictionary); + return FBResponseWithObject(stats); } + (id)handleDisconnect:(FBRouteRequest *)request { NSError *error; - if (![FBSocks5TunnelManager.sharedInstance disconnectWithError:&error]) { + NSDictionary *stats = [FBSocks5TunnelManager.sharedInstance disconnectWithError:&error]; + if (nil == stats) { return [self responseWithTunnelManagerError:error]; } - return FBResponseWithObject(FBSocks5TunnelManager.sharedInstance.statsDictionary); + return FBResponseWithObject(stats); } + (id)handleStats:(FBRouteRequest *)request diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h index eade53b6cc..1f65f0d37a 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h @@ -47,21 +47,24 @@ typedef NS_ERROR_ENUM(FBSocks5TunnelManagerErrorDomain, FBSocks5TunnelManagerErr @param consentButtonLabels labels to look for on the system "Add VPN Configurations" alert (defaults to "Allow"); pass others when the device language is not English @param error If there is an error, upon return contains an NSError describing the problem - @return NO in case of a failure + @return The stats snapshot taken while the tunnel lifecycle was still held, or nil on failure. + Returning it from inside that transaction is what keeps the response truthful: a + separate statsDictionary call would let an overlapping disconnect run in between and + make a successful connect report connected:false. */ -- (BOOL)connectWithURI:(FBSocks5URI *)uri - timeout:(NSTimeInterval)timeout - consentButtonLabels:(nullable NSArray *)consentButtonLabels - error:(NSError **)error; +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error; /** Stops the running tunnel (the VPN configuration stays installed). Succeeds when no tunnel is running. @param error If there is an error, upon return contains an NSError describing the problem - @return NO in case of a failure + @return The stats snapshot taken while the lifecycle was still held, or nil on failure. */ -- (BOOL)disconnectWithError:(NSError **)error; +- (nullable NSDictionary *)disconnectWithError:(NSError **)error; /** @return The stats payload for GET /mobilerun/socks5/stats: connected flag, proxy host/port/user (omitted while disconnected) and rx/tx byte/packet counters. Never fails; diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 2d68918c0c..793c0bec9c 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -48,19 +48,21 @@ + (instancetype)sharedInstance return instance; } -- (BOOL)connectWithURI:(FBSocks5URI *)uri - timeout:(NSTimeInterval)timeout - consentButtonLabels:(nullable NSArray *)consentButtonLabels - error:(NSError **)error +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error { - return FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, - @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); + FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, + @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); + return nil; } -- (BOOL)disconnectWithError:(NSError **)error +- (nullable NSDictionary *)disconnectWithError:(NSError **)error { - return FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, - @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); + FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, + @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); + return nil; } - (NSDictionary *)statsDictionary @@ -374,32 +376,40 @@ - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels #pragma mark - Public API -- (BOOL)connectWithURI:(FBSocks5URI *)uri - timeout:(NSTimeInterval)timeout - consentButtonLabels:(nullable NSArray *)consentButtonLabels - error:(NSError **)error +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error { - __block BOOL succeeded = NO; + // Start the clock before queueing, not inside the locked body: waiting behind another + // operation is part of the caller's wall-clock budget, and a request with timeout:1 that + // queued behind a 30s connect must not then be handed a fresh one-second budget. + NSTimeInterval budget = timeout > 0 ? timeout : FBSocks5DefaultConnectTimeout; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:budget]; + __block NSDictionary *snapshot = nil; __block NSError *localError = nil; [self performLocked:^{ - succeeded = [self lockedConnectWithURI:uri - timeout:timeout - consentButtonLabels:consentButtonLabels - error:&localError]; + if ([self lockedConnectWithURI:uri + deadline:deadline + consentButtonLabels:consentButtonLabels + error:&localError]) { + // Taken before the lock is released, so the payload cannot describe a tunnel that a + // queued disconnect has since torn down. + snapshot = [self lockedStatsDictionary]; + } }]; - if (!succeeded && nil != error) { + if (nil == snapshot && nil != error) { *error = localError; } - return succeeded; + return snapshot; } - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri - timeout:(NSTimeInterval)timeout + deadline:(NSDate *)deadline consentButtonLabels:(nullable NSArray *)consentButtonLabels error:(NSError **)error { - NSTimeInterval budget = timeout > 0 ? timeout : FBSocks5DefaultConnectTimeout; - NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:budget]; + NSTimeInterval budget = deadline.timeIntervalSinceNow; NSArray *labels = consentButtonLabels.count > 0 ? consentButtonLabels : @[@"Allow"]; [self fencePendingSaveWithDeadline:deadline]; @@ -538,17 +548,19 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri return YES; } -- (BOOL)disconnectWithError:(NSError **)error +- (nullable NSDictionary *)disconnectWithError:(NSError **)error { - __block BOOL succeeded = NO; + __block NSDictionary *snapshot = nil; __block NSError *localError = nil; [self performLocked:^{ - succeeded = [self lockedDisconnectWithError:&localError]; + if ([self lockedDisconnectWithError:&localError]) { + snapshot = [self lockedStatsDictionary]; + } }]; - if (!succeeded && nil != error) { + if (nil == snapshot && nil != error) { *error = localError; } - return succeeded; + return snapshot; } - (BOOL)lockedDisconnectWithError:(NSError **)error diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.m b/WebDriverAgentTunnel/FBTunnelHevRunner.m index c48b346312..21565d3997 100644 --- a/WebDriverAgentTunnel/FBTunnelHevRunner.m +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.m @@ -53,7 +53,14 @@ - (BOOL)stopAndWait:(NSTimeInterval)timeout if (!self.isRunning || nil == exitSemaphore) { return YES; } - hev_socks5_tunnel_quit(); + // hev_socks5_tunnel_quit() is not itself bounded: called before the engine has initialized its + // event fds - or concurrently with the engine exiting - it can block waiting for event_fds[1] + // to become valid. Waiting on the semaphore only afterwards would leave the whole stop + // unbounded, and NetworkExtension's stop callback would never fire. Issue the quit off the + // caller's thread so the deadline below covers the request as well as the exit. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + hev_socks5_tunnel_quit(); + }); return 0 == dispatch_semaphore_wait(exitSemaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC))); } diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m index 6b8b49a0c7..af132d5123 100644 --- a/WebDriverAgentTunnel/FBTunnelPacketProvider.m +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -139,15 +139,17 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia @"Cannot reach the SOCKS5 proxy at %@:%u: %s", proxyIP, port, strerror(err)]); } - BOOL hasCredentials = user.length > 0; - uint8_t greeting[4]; - size_t greetingLength = 0; - greeting[greetingLength++] = 0x05; - greeting[greetingLength++] = hasCredentials ? 2 : 1; - greeting[greetingLength++] = 0x00; // no authentication - if (hasCredentials) { - greeting[greetingLength++] = 0x02; // username/password - } + // Mirror hev_socks5_client_write_auth_methods exactly: it always offers a single method - + // username/password when BOTH fields are set, no-auth otherwise. Offering both here would let + // a proxy pick one hev never sends, so preflight would pass while every real session is + // rejected: a no-auth-only proxy with credentials, or a user-without-password URI, would both + // report connected:true over a tunnel that cannot carry traffic. + BOOL hasCredentials = user.length > 0 && pass.length > 0; + uint8_t greeting[3]; + greeting[0] = 0x05; + greeting[1] = 1; + greeting[2] = hasCredentials ? 0x02 : 0x00; + const size_t greetingLength = sizeof(greeting); if (send(fd, greeting, greetingLength, 0) != (ssize_t)greetingLength) { close(fd); return FBTunnelError(@"The SOCKS5 proxy closed the connection during the greeting"); @@ -166,12 +168,12 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia if (0xFF == choice[1]) { close(fd); return FBTunnelError(hasCredentials - ? @"The SOCKS5 proxy rejected both supported authentication methods" - : @"The SOCKS5 proxy requires authentication but no credentials were configured"); + ? @"The SOCKS5 proxy rejected username/password authentication" + : @"The SOCKS5 proxy requires authentication, but the URI has no user AND password pair"); } if (0x02 == choice[1]) { NSData *userData = [user dataUsingEncoding:NSUTF8StringEncoding]; - NSData *passData = [(pass ?: @"") dataUsingEncoding:NSUTF8StringEncoding]; + NSData *passData = [(NSString *)pass dataUsingEncoding:NSUTF8StringEncoding]; if (userData.length > 255 || passData.length > 255) { close(fd); return FBTunnelError(@"The SOCKS5 credentials exceed the 255 byte protocol limit"); diff --git a/lib/xcodebuild.ts b/lib/xcodebuild.ts index 3bcb1aec21..859b021d99 100644 --- a/lib/xcodebuild.ts +++ b/lib/xcodebuild.ts @@ -247,10 +247,11 @@ export class XcodeBuild { if (!(await fs.exists(scriptPath))) { return; } - if (await fs.exists(path.join(projectRoot, 'ThirdParty', 'HevSocks5Tunnel.xcframework'))) { - return; - } - this.log.info('Building the SOCKS5 tunnel engine, which the WebDriverAgentTunnel target links'); + // Deliberately not gated on the xcframework merely existing: one left over from an older + // checkout, a submodule bump, a script revision, or a half-finished build would then be + // linked forever. The script owns that decision - it compares a SHA-based stamp and exits + // cheaply when the output is current - so always let it run and answer. + this.log.debug('Making sure the SOCKS5 tunnel engine the WebDriverAgentTunnel target links is current'); try { await exec(scriptPath, [], {cwd: projectRoot}); } catch (e) { From c2f4258bfea759334be9ff1f9a43066df84f78fa Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:22:55 +0200 Subject: [PATCH 17/23] fix: report preference deadline misses as timeouts Both preference waits folded "the completion never arrived in time" and "the completion reported a failure" into FBSocks5TunnelManagerErrorInternal, which responseWithTunnelManagerError: surfaces as an unknown error. A plain deadline miss therefore did not produce the documented timeout response, and the message read "Cannot load the VPN preferences: timed out" - an internal fault describing a timeout. Split the two on the initial load and on the post-save reload: no completion means FBSocks5TunnelManagerErrorTimeout, and the internal classification is reserved for an actual error handed back by NetworkExtension. Verified on device: a connect with timeout:0.2 now answers timeout / "Timed out reloading the saved VPN configuration" where it previously answered unknown error. Co-Authored-By: Claude Fable 5 --- .../Utilities/FBSocks5TunnelManager.m | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 793c0bec9c..65a280f788 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -221,10 +221,16 @@ - (BOOL)loadAllManagers:(NSArray **)outManagers [[[[FBRunLoopSpinner new] timeout:budget] interval:0.05] spinUntilTrue:^BOOL{ return done; }]; - if (!done || nil != loadError) { + // Running out of budget is a timeout, not an internal fault: collapsing the two would surface + // a plain deadline miss as 'unknown error' instead of the documented timeout response. + if (!done) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out loading the VPN preferences"); + } + if (nil != loadError) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, [NSString stringWithFormat:@"Cannot load the VPN preferences: %@", - loadError.localizedDescription ?: @"timed out"]); + loadError.localizedDescription]); } if (nil != outManagers) { *outManagers = managers ?: @[]; @@ -496,10 +502,15 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri [[[[FBRunLoopSpinner new] timeout:FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout)] interval:0.05] spinUntilTrue:^BOOL{ return reloadDone; }]; - if (!reloadDone || nil != reloadError) { + // Same distinction as loadAllManagers:deadline:error: above. + if (!reloadDone) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out reloading the saved VPN configuration"); + } + if (nil != reloadError) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, [NSString stringWithFormat:@"Cannot reload the saved VPN configuration: %@", - reloadError.localizedDescription ?: @"timed out"]); + reloadError.localizedDescription]); } NSError *startError; From 504c16957be8422b68afe097b09a08c16137f404 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:51:08 +0200 Subject: [PATCH 18/23] feat: make the SOCKS5 tunnel extension an opt-in build The WebDriverAgentTunnel appex was a hard target dependency of WebDriverAgentRunner, so every runner build - plain xcodebuild, the Appium driver, build.sh, CI - required the hev-socks5-tunnel submodule and the engine xcframework, and every device build had to sign the appex. That blocked merging to master for users without an Apple developer subscription. The tunnel is now opt-in via dedicated schemes: - Drop the runner -> tunnel PBXTargetDependency; the default WebDriverAgentRunner(-nodebug) schemes build no appex, need neither the submodule nor the engine, and no longer carry the embed post-action (so a stale appex in shared DerivedData can never sneak into a default build). - Add WebDriverAgentRunnerTunnel(-nodebug) schemes that build the appex alongside the runner and keep the embed post-action. - build.sh: TARGET=tunnel_runner selects the tunnel scheme and is the only target that prepares the hev engine. - xcodebuild.ts: drop prepareSocks5Engine - the driver always builds the default scheme, which no longer links the engine. - connect now fails fast with 'unsupported operation' when the appex is not embedded in the host app, instead of a confusing NEVPN error (unit-tested via FBSocks5TunnelManager isTunnelExtensionEmbeddedInBundle:). - CI: submodule checkout only where still needed (wda-tests build job, npm publish packing the vendored engine sources); new iOS_Tunnel_Build jobs keep the appex + engine + embed path covered. Verified: fresh-DerivedData default-scheme build succeeds with the xcframework absent and embeds no tunnel appex; the tunnel scheme embeds it with the rewritten bundle id; UnitTests pass. Co-Authored-By: Claude Fable 5 --- .github/workflows/functional-test.yml | 4 - .github/workflows/prebuild.yml | 8 - .github/workflows/publish.js.yml | 10 +- .github/workflows/wda-tests.yml | 19 +- Scripts/build.sh | 10 +- WebDriverAgent.xcodeproj/project.pbxproj | 13 -- .../WebDriverAgentRunner-nodebug.xcscheme | 16 -- .../xcschemes/WebDriverAgentRunner.xcscheme | 16 -- ...ebDriverAgentRunnerTunnel-nodebug.xcscheme | 151 ++++++++++++++ .../WebDriverAgentRunnerTunnel.xcscheme | 194 ++++++++++++++++++ .../Utilities/FBSocks5TunnelManager.h | 10 + .../Utilities/FBSocks5TunnelManager.m | 29 +++ .../UnitTests/FBSocks5ConfigTests.m | 49 +++++ docs/socks5-tunnel.md | 31 ++- lib/xcodebuild.ts | 39 +--- 15 files changed, 471 insertions(+), 128 deletions(-) create mode 100644 WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunnerTunnel-nodebug.xcscheme create mode 100644 WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunnerTunnel.xcscheme diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 6dd5cda70f..df7f22240c 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -50,10 +50,6 @@ jobs: runs-on: ${{matrix.test_targets.HOST_OS}} steps: - uses: actions/checkout@v7 - with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. - submodules: recursive - uses: actions/setup-node@v7 with: node-version: lts/* diff --git a/.github/workflows/prebuild.yml b/.github/workflows/prebuild.yml index 3ed2b1e5c8..da5c0b2ce1 100644 --- a/.github/workflows/prebuild.yml +++ b/.github/workflows/prebuild.yml @@ -43,10 +43,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 - with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. - submodules: recursive - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "${{ env.XCODE_VERSION }}" @@ -93,10 +89,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 - with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. - submodules: recursive - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "${{ env.XCODE_VERSION }}" diff --git a/.github/workflows/publish.js.yml b/.github/workflows/publish.js.yml index e8f552adb1..8e64591f62 100644 --- a/.github/workflows/publish.js.yml +++ b/.github/workflows/publish.js.yml @@ -58,10 +58,6 @@ jobs: config: ${{ fromJSON(needs.build_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 - with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. - submodules: recursive - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "${{ env.XCODE_VERSION }}" @@ -90,8 +86,10 @@ jobs: steps: - uses: actions/checkout@v7 with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + # The published npm package ships ThirdParty/hev-socks5-tunnel (see the + # 'files' list in package.json) so consumers can build the opt-in + # WebDriverAgentRunnerTunnel scheme; without the submodule the pack + # would silently omit the engine sources. submodules: recursive - name: Use Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/wda-tests.yml b/.github/workflows/wda-tests.yml index 290031898b..ca3b80d57a 100644 --- a/.github/workflows/wda-tests.yml +++ b/.github/workflows/wda-tests.yml @@ -60,6 +60,8 @@ jobs: {"name": "Generic_tvOS_Build_Min_Xcode", "vm_image": "${{ env.MIN_VM_IMAGE }}", "xcode_version": "${{ env.MIN_XCODE_VERSION }}", "action": "build", "target": "tv_runner", "sdk": "tv_sim", "dest": "tv_generic", "code_sign": "no"}, {"name": "iOS_Build_Max_Xcode", "vm_image": "${{ env.MAX_VM_IMAGE }}", "xcode_version": "${{ env.MAX_XCODE_VERSION }}", "action": "build", "target": "runner", "sdk": "sim", "iphone_model": "${{ env.MAX_IPHONE_DEVICE_NAME }}", "ipad_model": "${{ env.MAX_IPAD_DEVICE_NAME }}", "ios_version": "${{ env.MAX_PLATFORM_VERSION }}"}, {"name": "iOS_Build_Min_Xcode", "vm_image": "${{ env.MIN_VM_IMAGE }}", "xcode_version": "${{ env.MIN_XCODE_VERSION }}", "action": "build", "target": "runner", "sdk": "sim", "iphone_model": "${{ env.MIN_IPHONE_DEVICE_NAME }}", "ipad_model": "${{ env.MIN_IPAD_DEVICE_NAME }}", "ios_version": "${{ env.MIN_PLATFORM_VERSION }}"}, + {"name": "iOS_Tunnel_Build_Max_Xcode", "vm_image": "${{ env.MAX_VM_IMAGE }}", "xcode_version": "${{ env.MAX_XCODE_VERSION }}", "action": "build", "target": "tunnel_runner", "sdk": "sim", "iphone_model": "${{ env.MAX_IPHONE_DEVICE_NAME }}", "ipad_model": "${{ env.MAX_IPAD_DEVICE_NAME }}", "ios_version": "${{ env.MAX_PLATFORM_VERSION }}"}, + {"name": "iOS_Tunnel_Build_Min_Xcode", "vm_image": "${{ env.MIN_VM_IMAGE }}", "xcode_version": "${{ env.MIN_XCODE_VERSION }}", "action": "build", "target": "tunnel_runner", "sdk": "sim", "iphone_model": "${{ env.MIN_IPHONE_DEVICE_NAME }}", "ipad_model": "${{ env.MIN_IPAD_DEVICE_NAME }}", "ios_version": "${{ env.MIN_PLATFORM_VERSION }}"}, {"name": "tvOS_Build_Max_Xcode", "vm_image": "${{ env.MAX_VM_IMAGE }}", "xcode_version": "${{ env.MAX_XCODE_VERSION }}", "action": "build", "target": "tv_runner", "sdk": "tv_sim", "tv_model": "${{ env.MAX_TV_DEVICE_NAME }}", "tv_version": "${{ env.MAX_TV_PLATFORM_VERSION }}"}, {"name": "tvOS_Build_Min_Xcode", "vm_image": "${{ env.MIN_VM_IMAGE }}", "xcode_version": "${{ env.MIN_XCODE_VERSION }}", "action": "build", "target": "tv_runner", "sdk": "tv_sim", "dest": "tv", "tv_model": "${{ env.MIN_TV_DEVICE_NAME }}", "tv_version": "${{ env.MIN_TV_PLATFORM_VERSION }}"} ] @@ -138,8 +140,9 @@ jobs: steps: - uses: actions/checkout@v7 with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. + # Only the tunnel_runner matrix entries need ThirdParty/hev-socks5-tunnel + # (and its nested submodules); checking it out unconditionally keeps the + # shared build job simple. submodules: recursive - uses: ./.github/actions/xcode-setup with: @@ -170,10 +173,6 @@ jobs: config: ${{ fromJSON(needs.analyze_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 - with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. - submodules: recursive - uses: ./.github/actions/xcode-setup with: xcode_version: ${{ matrix.config.xcode_version }} @@ -201,10 +200,6 @@ jobs: config: ${{ fromJSON(needs.unit_test_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 - with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. - submodules: recursive - uses: ./.github/actions/xcode-setup with: xcode_version: ${{ matrix.config.xcode_version }} @@ -230,10 +225,6 @@ jobs: config: ${{ fromJSON(needs.integration_test_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 - with: - # The tunnel target needs ThirdParty/hev-socks5-tunnel (and its nested - # submodules) present, otherwise Scripts/build.sh fails before Xcode runs. - submodules: recursive - uses: ./.github/actions/xcode-setup with: xcode_version: ${{ matrix.config.xcode_version }} diff --git a/Scripts/build.sh b/Scripts/build.sh index 31774fc61c..1428416be7 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -15,6 +15,7 @@ function define_xc_macros() { case "$TARGET" in "lib" ) XC_TARGET="WebDriverAgentLib";; "runner" ) XC_TARGET="WebDriverAgentRunner";; + "tunnel_runner" ) XC_TARGET="WebDriverAgentRunnerTunnel";; "tv_lib" ) XC_TARGET="WebDriverAgentLib_tvOS";; "tv_runner" ) XC_TARGET="WebDriverAgentRunner_tvOS";; "watch_lib" ) XC_TARGET="WebDriverAgentLib_watchOS";; @@ -121,10 +122,11 @@ function fastlane_test() { } function prepare_socks5_engine() { - # The runner scheme builds the WebDriverAgentTunnel appex, which links - # HevSocks5Tunnel.xcframework built from the hev-socks5-tunnel submodule - # (no-op when the build stamp is current). See docs/socks5-tunnel.md. - if [[ "$TARGET" != "runner" ]]; then + # Only the WebDriverAgentRunnerTunnel scheme builds the WebDriverAgentTunnel appex, which + # links HevSocks5Tunnel.xcframework built from the hev-socks5-tunnel submodule (no-op when + # the build stamp is current). Plain runner builds need neither the submodule nor the + # engine. See docs/socks5-tunnel.md. + if [[ "$TARGET" != "tunnel_runner" ]]; then return fi local script_dir diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index 5123520d46..37a37a0aa0 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -1540,13 +1540,6 @@ remoteGlobalIDString = FBCAFE000000000000005001; remoteInfo = WebDriverAgentBroadcast; }; - FBCAFE000000000000007109 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 91F9DAE11B99DBC2001349B2 /* Project object */; - proxyType = 1; - remoteGlobalIDString = FBCAFE000000000000007101; - remoteInfo = WebDriverAgentTunnel; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -4672,7 +4665,6 @@ dependencies = ( EE158B5C1CBD462500A3E3F0 /* PBXTargetDependency */, FBCAFE00000000000000500A /* PBXTargetDependency */, - FBCAFE00000000000000710A /* PBXTargetDependency */, ); name = WebDriverAgentRunner; productName = XCTUITestRunner; @@ -5665,11 +5657,6 @@ target = FBCAFE000000000000005001 /* WebDriverAgentBroadcast */; targetProxy = FBCAFE000000000000005009 /* PBXContainerItemProxy */; }; - FBCAFE00000000000000710A /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = FBCAFE000000000000007101 /* WebDriverAgentTunnel */; - targetProxy = FBCAFE000000000000007109 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme index 0308efaf81..57692f8224 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme @@ -38,22 +38,6 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunnerTunnel.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunnerTunnel.xcscheme new file mode 100644 index 0000000000..8278fc0e8d --- /dev/null +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunnerTunnel.xcscheme @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h index 1f65f0d37a..be79ed7c9f 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h @@ -38,6 +38,16 @@ typedef NS_ERROR_ENUM(FBSocks5TunnelManagerErrorDomain, FBSocks5TunnelManagerErr + (instancetype)sharedInstance; +/** + Whether the given host bundle embeds the WebDriverAgentTunnel packet tunnel extension in its + PlugIns directory. Only the WebDriverAgentRunnerTunnel schemes embed it; the default runner + schemes build without the extension, and connect then fails with + FBSocks5TunnelManagerErrorUnsupported (see docs/socks5-tunnel.md). + + @param bundle the host app bundle to inspect (production code passes NSBundle.mainBundle) + */ ++ (BOOL)isTunnelExtensionEmbeddedInBundle:(NSBundle *)bundle; + /** Installs/updates the VPN configuration for the given proxy and starts the tunnel. An already-running tunnel is replaced. Returns once the tunnel reports connected. diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 65a280f788..c2ab784aed 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -23,6 +23,18 @@ static BOOL FBSocks5Fail(NSError **error, FBSocks5TunnelManagerError code, NSStr return NO; } +// The tunnel appex is only embedded by the WebDriverAgentRunnerTunnel schemes (the default +// runner schemes build without it so the hev submodule and paid-team signing stay optional); +// its presence in the host app is what decides whether SOCKS5 support exists in this build. +static BOOL FBSocks5TunnelExtensionEmbedded(NSBundle *bundle) +{ + NSString *appexPath = [bundle.bundlePath + stringByAppendingPathComponent:@"PlugIns/WebDriverAgentTunnel.appex"]; + BOOL isDirectory = NO; + return [NSFileManager.defaultManager fileExistsAtPath:appexPath isDirectory:&isDirectory] + && isDirectory; +} + static NSMutableDictionary *FBSocks5DisconnectedStats(void) { return [@{ @@ -70,6 +82,11 @@ + (instancetype)sharedInstance return FBSocks5DisconnectedStats().copy; } ++ (BOOL)isTunnelExtensionEmbeddedInBundle:(NSBundle *)bundle +{ + return FBSocks5TunnelExtensionEmbedded(bundle); +} + @end #else @@ -152,6 +169,11 @@ + (instancetype)sharedInstance return instance; } ++ (BOOL)isTunnelExtensionEmbeddedInBundle:(NSBundle *)bundle +{ + return FBSocks5TunnelExtensionEmbedded(bundle); +} + - (instancetype)init { self = [super init]; @@ -392,6 +414,13 @@ - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels // queued behind a 30s connect must not then be handed a fresh one-second budget. NSTimeInterval budget = timeout > 0 ? timeout : FBSocks5DefaultConnectTimeout; NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:budget]; + if (!FBSocks5TunnelExtensionEmbedded(NSBundle.mainBundle)) { + FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, + @"This build does not embed the WebDriverAgentTunnel extension; build the" + " WebDriverAgentRunnerTunnel scheme to include SOCKS5 VPN support" + " (see docs/socks5-tunnel.md)"); + return nil; + } __block NSDictionary *snapshot = nil; __block NSError *localError = nil; [self performLocked:^{ diff --git a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m index 116f4e8354..6248057195 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m @@ -8,10 +8,12 @@ #import +#import "FBSocks5TunnelManager.h" #import "FBSocks5TunnelProtocol.h" #import "FBSocks5URI.h" @interface FBSocks5ConfigTests : XCTestCase +@property (nonatomic, nullable, copy) NSString *tempBundleRoot; @end @implementation FBSocks5ConfigTests @@ -74,4 +76,51 @@ - (void)testYAMLEnablesMapDNSForRemoteResolution XCTAssertTrue([yaml containsString:dnsLine]); } +#pragma mark - Tunnel extension presence + +- (NSBundle *)makeFakeRunnerBundleWithTunnelAppex:(BOOL)withAppex +{ + NSString *root = [NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + NSString *plugIns = [root stringByAppendingPathComponent:@"PlugIns"]; + NSError *error; + XCTAssertTrue([NSFileManager.defaultManager createDirectoryAtPath:plugIns + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + if (withAppex) { + NSString *appex = [plugIns stringByAppendingPathComponent:@"WebDriverAgentTunnel.appex"]; + XCTAssertTrue([NSFileManager.defaultManager createDirectoryAtPath:appex + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + } + self.tempBundleRoot = root; + NSBundle *bundle = [NSBundle bundleWithPath:root]; + XCTAssertNotNil(bundle); + return bundle; +} + +- (void)tearDown +{ + if (nil != self.tempBundleRoot) { + [NSFileManager.defaultManager removeItemAtPath:self.tempBundleRoot error:nil]; + self.tempBundleRoot = nil; + } + [super tearDown]; +} + +- (void)testTunnelExtensionDetectedWhenAppexEmbedded +{ + NSBundle *bundle = [self makeFakeRunnerBundleWithTunnelAppex:YES]; + XCTAssertTrue([FBSocks5TunnelManager isTunnelExtensionEmbeddedInBundle:bundle]); +} + +- (void)testTunnelExtensionNotDetectedWithoutAppex +{ + NSBundle *bundle = [self makeFakeRunnerBundleWithTunnelAppex:NO]; + XCTAssertFalse([FBSocks5TunnelManager isTunnelExtensionEmbeddedInBundle:bundle]); +} + @end diff --git a/docs/socks5-tunnel.md b/docs/socks5-tunnel.md index 93cc26ac1d..a2d5be3a85 100644 --- a/docs/socks5-tunnel.md +++ b/docs/socks5-tunnel.md @@ -1,12 +1,18 @@ # SOCKS5 VPN Tunnel -WebDriverAgent embeds a NetworkExtension **packet tunnel provider** +WebDriverAgent can embed a NetworkExtension **packet tunnel provider** (`WebDriverAgentTunnel.appex`) into the generated `WebDriverAgentRunner-Runner.app`. When connected, the tunnel captures the device's IPv4 traffic on a virtual interface and forwards it through a SOCKS5 proxy using the [hev-socks5-tunnel](https://github.com/heiher/hev-socks5-tunnel) engine (MIT) running inside the extension. WDA configures and controls the tunnel in-process via `NETunnelProviderManager`. +The extension is **opt-in at build time**: only the `WebDriverAgentRunnerTunnel` / +`WebDriverAgentRunnerTunnel-nodebug` schemes build and embed it. The default +`WebDriverAgentRunner` / `WebDriverAgentRunner-nodebug` schemes produce a runner without the +extension — no git submodule, no engine build and no paid team needed — whose socks5 +endpoints answer `unsupported operation`. + The tunnel only works on **physical iOS devices** (packet tunnel providers do not run on the Simulator or tvOS — the endpoints answer `unsupported operation` there) and requires **paid-team signing**: free/personal Apple developer teams cannot register App IDs with the @@ -61,7 +67,13 @@ Network Extension capability. `host`/`port`/`user` are omitted while disconnected (`user` also when the proxy needs no auth). Counters are cumulative since the tunnel start and reset on reconnect. -## Build +## Build (opt-in) + +The default runner schemes do not reference the appex at all; `connect` on such a build +fails fast with `unsupported operation: this build does not embed the WebDriverAgentTunnel +extension` (`disconnect` and `stats` keep answering as disconnected). To include the tunnel, +build a `WebDriverAgentRunnerTunnel*` scheme — it builds the `WebDriverAgentTunnel` appex +alongside the runner and carries the embed post-action. The engine is vendored as a git submodule and compiled into a static-library xcframework that only the appex links: @@ -71,14 +83,15 @@ git submodule update --init --recursive Scripts/build-hev-socks5-tunnel.sh # -> ThirdParty/HevSocks5Tunnel.xcframework (gitignored) ``` -`Scripts/build.sh` runs the engine build automatically for runner targets, and it is a no-op -while the stamp file matches the submodule SHA (`--force` rebuilds). Plain `xcodebuild` -invocations need the script run once beforehand. +`Scripts/build.sh` runs the engine build automatically for `TARGET=tunnel_runner`, and it is +a no-op while the stamp file matches the submodule SHA (`--force` rebuilds). Plain +`xcodebuild` invocations of the tunnel schemes need the script run once beforehand. Like the broadcast extension, the appex cannot reach the Xcode-generated `Runner.app` through -a regular embed phase; the scheme post-action `Scripts/embed-tunnel-extension.sh` copies it -into `Runner.app/PlugIns`, rewrites its bundle id to `.tunnel` and re-signs -inner-first (post-actions run on scheme-based CLI builds, including `build-for-testing`). +a regular embed phase; the tunnel schemes' post-action `Scripts/embed-tunnel-extension.sh` +copies it into `Runner.app/PlugIns`, rewrites its bundle id to `.tunnel` and +re-signs inner-first (post-actions run on scheme-based CLI builds, including +`build-for-testing`). ## Signing (device) @@ -92,7 +105,7 @@ The entitlements are wired through build variables that default to empty, so reg them explicitly: ```bash -xcodebuild build-for-testing -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner \ +xcodebuild build-for-testing -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunnerTunnel \ -destination 'id=' -allowProvisioningUpdates \ DEVELOPMENT_TEAM= \ WDA_RUNNER_ENTITLEMENTS=WebDriverAgentRunner/WebDriverAgentRunner.entitlements \ diff --git a/lib/xcodebuild.ts b/lib/xcodebuild.ts index 859b021d99..63a1d02411 100644 --- a/lib/xcodebuild.ts +++ b/lib/xcodebuild.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import {fs, logger, timing, util} from '@appium/support'; +import {logger, timing, util} from '@appium/support'; import type {AppiumLogger, StringRecord} from '@appium/types'; import {retryInterval} from 'asyncbox'; import {SubProcess, exec} from 'teen_process'; @@ -226,42 +226,6 @@ export class XcodeBuild { } } - /** - * Builds ThirdParty/HevSocks5Tunnel.xcframework if it is not there yet. - * - * The WebDriverAgentTunnel target links that xcframework, and it is a gitignored build - * product, so a fresh clone or an npm install does not have one. Scripts/build.sh prepares it - * for the CI and bundling flows, but this class invokes xcodebuild directly and would - * otherwise fail immediately with "There is no XCFramework found". It cannot be fixed inside - * the Xcode project: xcodebuild resolves XCFramework references while computing the build - * graph, before any build phase of the target - or of a target it depends on - gets to run. - * - * The script is a no-op once its stamp matches, so this only costs a process spawn on - * subsequent launches. A failure here is logged and swallowed rather than thrown: xcodebuild - * reports the missing framework with a clearer message, and installations that never touch - * the tunnel should not be blocked by an engine build. - */ - async prepareSocks5Engine(): Promise { - const projectRoot = path.dirname(this.agentPath); - const scriptPath = path.join(projectRoot, 'Scripts', 'build-hev-socks5-tunnel.sh'); - if (!(await fs.exists(scriptPath))) { - return; - } - // Deliberately not gated on the xcframework merely existing: one left over from an older - // checkout, a submodule bump, a script revision, or a half-finished build would then be - // linked forever. The script owns that decision - it compares a SHA-based stamp and exits - // cheaply when the output is current - so always let it run and answer. - this.log.debug('Making sure the SOCKS5 tunnel engine the WebDriverAgentTunnel target links is current'); - try { - await exec(scriptPath, [], {cwd: projectRoot}); - } catch (e) { - this.log.warn( - `Cannot build the SOCKS5 tunnel engine: ${(e as Error).message}. ` + - `The WebDriverAgentTunnel target will not build until ${scriptPath} succeeds.` - ); - } - } - /** * Starts the xcodebuild process to build and/or test WebDriverAgent. * @param buildOnly - If `true`, only builds without running tests. Defaults to `false`. @@ -269,7 +233,6 @@ export class XcodeBuild { * @throws Error if xcodebuild fails or cannot start */ async start(buildOnly: boolean = false): Promise { - await this.prepareSocks5Engine(); this.xcodebuild = await this.createSubProcess(buildOnly); // wrap the start procedure in a promise so that we can catch, and report, From bb985e38b806a9b76c74b00eb87e8ab575c7c4fc Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:12:37 +0200 Subject: [PATCH 19/23] fix: address the opt-in review round (stop/start race, DNS failover, stats deadline) - Abort provider startup after a concurrent stop: stopTunnelWithReason: and the startup continuation now transition {isStopping, runner} under one lock, and the runner is started inside the section that publishes it. A stop that lands while the probe or setTunnelNetworkSettings is pending previously found no runner, completed as a no-op, and the continuation then started a late hev engine in a provider the system considered torn down. - Try every resolved proxy address before failing: the pre-flight now probes all A/AAAA records in order (IPv4 first, deduplicated) and keeps the first that completes the SOCKS5 handshake. Unreachable endpoints move on to the next record; protocol/credential rejections still fail immediately since they come from the server itself. The probe's connect() is now bounded by the 8s probe deadline via a non-blocking connect + poll (SO_SNDTIMEO never covered connect, so a filtered endpoint could previously hang the start for the ~75s SYN retry window - per address, once several are tried). - Keep the connect success snapshot within the caller's timeout: the stats round trip to the extension is now capped at the remaining connect deadline (and skipped when exhausted, falling back to zero counters) instead of a fixed extra 3s. Two further findings from the same round were already resolved by the opt-in change: the release workflows no longer need the hev xcframework (they build the default tunnel-free schemes), and prepareSocks5Engine no longer exists in lib/xcodebuild.ts. Co-Authored-By: Claude Fable 5 --- .../Utilities/FBSocks5TunnelManager.m | 19 ++- WebDriverAgentTunnel/FBTunnelPacketProvider.m | 149 ++++++++++++++---- 2 files changed, 133 insertions(+), 35 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index c2ab784aed..915ee8cd64 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -429,8 +429,10 @@ - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels consentButtonLabels:consentButtonLabels error:&localError]) { // Taken before the lock is released, so the payload cannot describe a tunnel that a - // queued disconnect has since torn down. - snapshot = [self lockedStatsDictionary]; + // queued disconnect has since torn down. Capped at the caller's deadline: the documented + // timeout covers the whole connect flow, so a slow provider stats reply must trim the + // counters (they fall back to zero) rather than blow the budget. + snapshot = [self lockedStatsDictionaryWithDeadline:deadline]; } }]; if (nil == snapshot && nil != error) { @@ -642,6 +644,11 @@ - (BOOL)lockedDisconnectWithError:(NSError **)error } - (NSDictionary *)lockedStatsDictionary +{ + return [self lockedStatsDictionaryWithDeadline:nil]; +} + +- (NSDictionary *)lockedStatsDictionaryWithDeadline:(nullable NSDate *)deadline { NSMutableDictionary *stats = FBSocks5DisconnectedStats(); NETunnelProviderManager *manager = self.activeManager; @@ -665,6 +672,12 @@ - (BOOL)lockedDisconnectWithError:(NSError **)error stats[FBSocks5StatsKeyUser] = config[FBSocks5KeyUser]; } + // Counters are best-effort: with the caller's budget already exhausted, skip the round trip + // to the extension instead of stretching the response past the documented timeout. + NSTimeInterval statsBudget = FBSocks5RemainingTimeout(deadline, FBSocks5StatsReplyTimeout); + if (statsBudget <= 0) { + return stats.copy; + } NETunnelProviderSession *session = (NETunnelProviderSession *)manager.connection; __block NSDictionary *counters = nil; __block BOOL done = NO; @@ -681,7 +694,7 @@ - (BOOL)lockedDisconnectWithError:(NSError **)error done = YES; }]; if (sent) { - [[[[FBRunLoopSpinner new] timeout:FBSocks5StatsReplyTimeout] interval:0.05] spinUntilTrue:^BOOL{ + [[[[FBRunLoopSpinner new] timeout:statsBudget] interval:0.05] spinUntilTrue:^BOOL{ return done; }]; } else { diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m index af132d5123..fbb8bd3606 100644 --- a/WebDriverAgentTunnel/FBTunnelPacketProvider.m +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -9,7 +9,9 @@ #import "FBTunnelPacketProvider.h" #include +#include #include +#include #import "FBSocks5TunnelProtocol.h" #import "FBTunFdFinder.h" @@ -24,10 +26,14 @@ userInfo:@{NSLocalizedDescriptionKey: message}]; } -// Resolves a proxy host to a literal IP. The engine must not resolve it itself: once the -// tunnel's DNS settings are active, an in-provider lookup would be routed back into the -// tunnel that is not functional yet. Prefers IPv4; falls back to IPv6. -static NSString *FBTunnelResolveHost(NSString *host, BOOL *isIPv6) +// Resolves a proxy host to its literal IPs, IPv4 first (the engine's preferred family), each +// family preserving resolver order, without duplicates. The engine must not resolve the host +// itself: once the tunnel's DNS settings are active, an in-provider lookup would be routed +// back into the tunnel that is not functional yet. Every candidate is returned rather than +// just the first per family, so the pre-flight can fail over to later A/AAAA records when the +// first one is unreachable. An entry's family is recoverable from the literal itself (IPv6 +// literals contain ':'). +static NSArray *FBTunnelResolveHostAddresses(NSString *host) { struct addrinfo hints; memset(&hints, 0, sizeof(hints)); @@ -35,29 +41,32 @@ hints.ai_socktype = SOCK_STREAM; struct addrinfo *results = NULL; if (0 != getaddrinfo(host.UTF8String, NULL, &hints, &results)) { - return nil; + return @[]; } - NSString *ipv4 = nil; - NSString *ipv6 = nil; + NSMutableArray *ipv4 = [NSMutableArray array]; + NSMutableArray *ipv6 = [NSMutableArray array]; for (struct addrinfo *entry = results; NULL != entry; entry = entry->ai_next) { char buffer[INET6_ADDRSTRLEN] = {0}; - if (AF_INET == entry->ai_family && nil == ipv4) { + if (AF_INET == entry->ai_family) { struct sockaddr_in *addr = (struct sockaddr_in *)entry->ai_addr; if (NULL != inet_ntop(AF_INET, &addr->sin_addr, buffer, sizeof(buffer))) { - ipv4 = [NSString stringWithUTF8String:buffer]; + NSString *literal = [NSString stringWithUTF8String:buffer]; + if (![ipv4 containsObject:literal]) { + [ipv4 addObject:literal]; + } } - } else if (AF_INET6 == entry->ai_family && nil == ipv6) { + } else if (AF_INET6 == entry->ai_family) { struct sockaddr_in6 *addr = (struct sockaddr_in6 *)entry->ai_addr; if (NULL != inet_ntop(AF_INET6, &addr->sin6_addr, buffer, sizeof(buffer))) { - ipv6 = [NSString stringWithUTF8String:buffer]; + NSString *literal = [NSString stringWithUTF8String:buffer]; + if (![ipv6 containsObject:literal]) { + [ipv6 addObject:literal]; + } } } } freeaddrinfo(results); - if (NULL != isIPv6) { - *isIPv6 = (nil == ipv4 && nil != ipv6); - } - return ipv4 ?: ipv6; + return [ipv4 arrayByAddingObjectsFromArray:ipv6]; } /// How long the pre-flight SOCKS5 handshake may take before the proxy counts as unreachable. @@ -95,12 +104,19 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia would report success for a proxy that is unreachable or rejects the credentials, and every packet routed into the tunnel would be silently blackholed. Runs before the tunnel's network settings are applied, so it cannot be captured by the tunnel it is validating. + + `outUnreachable` is set when the failure happened before any SOCKS5 byte was exchanged + (socket/connect failure): only those failures are worth retrying on another resolved address, + while a protocol or credential rejection comes from the proxy itself and would repeat there. */ static NSError *_Nullable FBTunnelProbeSocks5(NSString *proxyIP, BOOL isIPv6, uint16_t port, - NSString *_Nullable user, NSString *_Nullable pass) + NSString *_Nullable user, NSString *_Nullable pass, + BOOL *outUnreachable) { + *outUnreachable = NO; int fd = socket(isIPv6 ? AF_INET6 : AF_INET, SOCK_STREAM, 0); if (fd < 0) { + *outUnreachable = YES; return FBTunnelError(@"Cannot create a socket to probe the SOCKS5 proxy"); } // A proxy that closes the connection mid-handshake makes send() raise SIGPIPE on Darwin, @@ -110,6 +126,13 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)); FBTunnelSetSocketTimeout(fd, FBTunnelProbeTimeout); + // SO_RCVTIMEO/SO_SNDTIMEO do not bound connect(); a blocking connect to a silently filtered + // endpoint would hang for the system's SYN-retry window (~75s) - per candidate address, now + // that several may be probed. Connect non-blocking under the probe deadline instead, then + // restore blocking mode so the handshake keeps relying on the socket timeouts above. + int flags = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + int connected = -1; if (isIPv6) { struct sockaddr_in6 addr; @@ -132,12 +155,31 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia } connected = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); } + if (0 != connected && EINPROGRESS == errno) { + struct pollfd pfd; + memset(&pfd, 0, sizeof(pfd)); + pfd.fd = fd; + pfd.events = POLLOUT; + if (1 == poll(&pfd, 1, (int)(FBTunnelProbeTimeout * 1000))) { + int socketError = 0; + socklen_t errorLength = sizeof(socketError); + if (0 == getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketError, &errorLength) && 0 == socketError) { + connected = 0; + } else { + errno = 0 != socketError ? socketError : ETIMEDOUT; + } + } else { + errno = ETIMEDOUT; + } + } if (0 != connected) { int err = errno; close(fd); + *outUnreachable = YES; return FBTunnelError([NSString stringWithFormat: @"Cannot reach the SOCKS5 proxy at %@:%u: %s", proxyIP, port, strerror(err)]); } + fcntl(fd, F_SETFL, flags); // Mirror hev_socks5_client_write_auth_methods exactly: it always offers a single method - // username/password when BOTH fields are set, no-auth otherwise. Offering both here would let @@ -230,21 +272,39 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) return; } - BOOL proxyIsIPv6 = NO; - NSString *proxyIP = FBTunnelResolveHost(host, &proxyIsIPv6); - if (nil == proxyIP) { + NSArray *candidates = FBTunnelResolveHostAddresses(host); + if (0 == candidates.count) { completionHandler(FBTunnelError([NSString stringWithFormat:@"Cannot resolve the SOCKS5 proxy host '%@'", host])); return; } NSLog(@"WebDriverAgentTunnel: starting tunnel through %@:%@ (resolved %@, remoteDNS=%d)", - host, port, proxyIP, remoteDNS); + host, port, [candidates componentsJoinedByString:@", "], remoteDNS); // Fail before any routes are installed, so an unreachable proxy or bad credentials surface as - // a start error instead of a "connected" tunnel that drops every packet. - NSError *probeError = FBTunnelProbeSocks5(proxyIP, proxyIsIPv6, (uint16_t)port.unsignedIntValue, - config[FBSocks5KeyUser], config[FBSocks5KeyPass]); - if (nil != probeError) { - NSLog(@"WebDriverAgentTunnel: proxy pre-flight failed: %@", probeError.localizedDescription); + // a start error instead of a "connected" tunnel that drops every packet. Probing the resolved + // addresses in order gives the usual DNS failover: an unreachable endpoint moves on to the + // next record, while a protocol/credential rejection is answered by the proxy itself and + // fails immediately (it would repeat on every address of the same server). + NSString *proxyIP = nil; + BOOL proxyIsIPv6 = NO; + NSError *probeError = nil; + for (NSString *candidate in candidates) { + BOOL candidateIsIPv6 = [candidate containsString:@":"]; + BOOL unreachable = NO; + probeError = FBTunnelProbeSocks5(candidate, candidateIsIPv6, (uint16_t)port.unsignedIntValue, + config[FBSocks5KeyUser], config[FBSocks5KeyPass], &unreachable); + if (nil == probeError) { + proxyIP = candidate; + proxyIsIPv6 = candidateIsIPv6; + break; + } + NSLog(@"WebDriverAgentTunnel: proxy pre-flight failed for %@: %@", + candidate, probeError.localizedDescription); + if (!unreachable) { + break; + } + } + if (nil == proxyIP) { completionHandler(probeError); return; } @@ -304,12 +364,11 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) completionHandler(FBTunnelError(@"Cannot locate the utun file descriptor in the tunnel provider")); return; } - // The engine must connect to the already resolved address (see FBTunnelResolveHost). + // The engine must connect to the already probed address (see FBTunnelResolveHostAddresses). NSMutableDictionary *engineConfig = [config mutableCopy]; engineConfig[FBSocks5KeyHost] = proxyIP; NSString *yaml = FBSocks5HevConfigFromProviderConfiguration(engineConfig); FBTunnelHevRunner *runner = [[FBTunnelHevRunner alloc] init]; - strongSelf.runner = runner; // An engine that fails to initialize returns from its main almost immediately. Report that // as a start failure rather than letting NetworkExtension reach NEVPNStatusConnected; once // startup has been acknowledged, an unexpected exit tears the tunnel down instead of leaving @@ -323,7 +382,7 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) NSObject *startupLock = [NSObject new]; __block BOOL startupAcknowledged = NO; __block BOOL engineExited = NO; - [runner startWithConfigYAML:yaml tunFd:tunFd exitHandler:^(int exitCode) { + void (^exitHandler)(int) = ^(int exitCode) { __strong typeof(weakSelf) exitSelf = weakSelf; BOOL shouldCancel = NO; @synchronized (startupLock) { @@ -337,7 +396,26 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) NSLog(@"WebDriverAgentTunnel: engine exited unexpectedly (code %d); cancelling the tunnel", exitCode); [exitSelf cancelTunnelWithError: FBTunnelError([NSString stringWithFormat:@"The SOCKS5 engine exited unexpectedly with code %d", exitCode])]; - }]; + }; + // Publishing and starting the runner must be atomic against stopTunnelWithReason:. A stop + // that arrives while the probe or setTunnelNetworkSettings is still pending finds no runner + // to stop and completes; without this section the continuation would then start a late + // engine inside a provider the system already considers torn down. Under the lock exactly + // one path wins: either the stop already recorded isStopping and startup aborts before the + // engine exists, or the runner is published and started first and the stop finds it. + BOOL stoppedDuringStartup = NO; + @synchronized (strongSelf) { + if (strongSelf.isStopping) { + stoppedDuringStartup = YES; + } else { + strongSelf.runner = runner; + [runner startWithConfigYAML:yaml tunFd:tunFd exitHandler:exitHandler]; + } + } + if (stoppedDuringStartup) { + completionHandler(FBTunnelError(@"The tunnel was stopped while it was still starting up")); + return; + } dispatch_semaphore_wait(settled, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(FBTunnelEngineSettleTimeout * NSEC_PER_SEC))); BOOL startupFailed = NO; @@ -361,9 +439,16 @@ - (void)stopTunnelWithReason:(NEProviderStopReason)reason completionHandler:(void (^)(void))completionHandler { NSLog(@"WebDriverAgentTunnel: stopping tunnel (reason %ld)", (long)reason); - self.isStopping = YES; - [self.runner stopAndWait:5.0]; - self.runner = nil; + FBTunnelHevRunner *runner; + // Atomic against the startup continuation (see startTunnelWithOptions:): after this section + // a startup that has not yet published its runner is guaranteed to observe isStopping and + // abort instead of starting a late engine. + @synchronized (self) { + self.isStopping = YES; + runner = self.runner; + self.runner = nil; + } + [runner stopAndWait:5.0]; completionHandler(); } From 6ef1fff1d3650f346f0cd3f99f3221aa82c958f7 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:11:45 +0200 Subject: [PATCH 20/23] fix: harden SOCKS5 tunnel lifecycle --- .gitignore | 1 + Configurations/IOSSettings.xcconfig | 1 + Configurations/TVOSSettings.xcconfig | 1 + Scripts/build-hev-socks5-tunnel.sh | 87 ++-- Scripts/embed-tunnel-extension.sh | 50 +- WebDriverAgent.xcodeproj/project.pbxproj | 464 ++++++++--------- .../xcschemes/IntegrationApp_watchOS.xcscheme | 2 + .../Commands/FBMobilerunSocks5Commands.m | 45 +- WebDriverAgentLib/Routing/FBHTTPServer.h | 8 +- WebDriverAgentLib/Routing/FBHTTPServer.m | 38 +- .../Routing/FBRouteRequest-Private.h | 1 + WebDriverAgentLib/Routing/FBRouteRequest.h | 8 + WebDriverAgentLib/Routing/FBRouteRequest.m | 90 +++- WebDriverAgentLib/Routing/FBWebServer.h | 16 +- WebDriverAgentLib/Routing/FBWebServer.m | 121 ++++- WebDriverAgentLib/Routing/RouteRequest.h | 4 +- WebDriverAgentLib/Routing/RouteRequest.m | 2 + .../Utilities/FBSocks5TunnelManager.h | 6 + .../Utilities/FBSocks5TunnelManager.m | 311 ++++++++---- .../Utilities/FBSocks5TunnelProtocol.h | 37 ++ .../Utilities/FBSocks5TunnelProtocol.m | 141 ++++++ WebDriverAgentLib/Utilities/FBSocks5URI.h | 3 + WebDriverAgentLib/Utilities/FBSocks5URI.m | 58 ++- WebDriverAgentTests/UnitTests/FBRouteTests.m | 209 ++++++++ .../UnitTests/FBSocks5ConfigTests.m | 211 ++++++++ .../UnitTests/FBSocks5URITests.m | 29 +- WebDriverAgentTunnel/FBTunnelPacketProvider.m | 472 ++++++++++++++---- docs/socks5-tunnel.md | 16 +- lib/xcodebuild.ts | 2 +- test/unit/webdriveragent.spec.ts | 12 + 30 files changed, 1888 insertions(+), 558 deletions(-) diff --git a/.gitignore b/.gitignore index 8b4dc84aa4..dd3fdf2fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ Gemfile.lock # Built from the hev-socks5-tunnel submodule (Scripts/build-hev-socks5-tunnel.sh) ThirdParty/HevSocks5Tunnel.xcframework/ ThirdParty/.hev-socks5-tunnel.stamp +ThirdParty/.hev-socks5-tunnel.lock diff --git a/Configurations/IOSSettings.xcconfig b/Configurations/IOSSettings.xcconfig index 3bde5a21f2..a0fc8a1f11 100644 --- a/Configurations/IOSSettings.xcconfig +++ b/Configurations/IOSSettings.xcconfig @@ -1,4 +1,5 @@ EXCLUDED_ARCHS = i386 +WDA_PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner GCC_TREAT_WARNINGS_AS_ERRORS = YES GCC_WARN_PEDANTIC = YES diff --git a/Configurations/TVOSSettings.xcconfig b/Configurations/TVOSSettings.xcconfig index 3bde5a21f2..a0fc8a1f11 100644 --- a/Configurations/TVOSSettings.xcconfig +++ b/Configurations/TVOSSettings.xcconfig @@ -1,4 +1,5 @@ EXCLUDED_ARCHS = i386 +WDA_PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner GCC_TREAT_WARNINGS_AS_ERRORS = YES GCC_WARN_PEDANTIC = YES diff --git a/Scripts/build-hev-socks5-tunnel.sh b/Scripts/build-hev-socks5-tunnel.sh index d5486e7fe8..d193e77b8c 100755 --- a/Scripts/build-hev-socks5-tunnel.sh +++ b/Scripts/build-hev-socks5-tunnel.sh @@ -26,71 +26,60 @@ if [ ! -f "$SUBMODULE_DIR/src/hev-main.h" ]; then exit 1 fi -# The npm tarball ships the engine sources without any Git metadata, so the submodule SHA is -# only available in a Git checkout. Fall back to hashing the source tree there, which keys the -# stamp on exactly what is about to be compiled either way. -if SUBMODULE_SHA=$(git -C "$SUBMODULE_DIR" rev-parse HEAD 2>/dev/null); then - SOURCE_ID="$SUBMODULE_SHA" -else - SOURCE_ID=$(find "$SUBMODULE_DIR/src" -type f -exec shasum -a 256 {} + \ - | sort | shasum -a 256 | cut -d' ' -f1) - SUBMODULE_SHA="tree-$SOURCE_ID" -fi SCRIPT_SHA=$(shasum -a 256 "$0" | cut -d' ' -f1) -STAMP="${SOURCE_ID}-${SCRIPT_SHA}-${MIN_IOS}" # build_static runs `make clean` in the shared submodule checkout, so two concurrent # preparations (e.g. two WDA sessions starting at once) would delete each other's archives # mid-compile or mid-libtool and produce nondeterministic failures or a corrupt xcframework. # Serialize across processes, and hold the lock across the stamp check too: the loser then # re-reads the stamp the winner just wrote and exits early instead of rebuilding. -LOCK_DIR="$ROOT_DIR/ThirdParty/.hev-socks5-tunnel.lock" -LOCK_WAIT_SECONDS=900 -LOCK_HELD=0 +LOCK_FILE="$ROOT_DIR/ThirdParty/.hev-socks5-tunnel.lock" +LOCK_WAIT_SECONDS="${HEV_SOCKS5_LOCK_WAIT_SECONDS:-900}" BUILD_DIR="" cleanup() { [ -n "$BUILD_DIR" ] && rm -rf "$BUILD_DIR" - [ "$LOCK_HELD" = "1" ] && rm -rf "$LOCK_DIR" return 0 } trap cleanup EXIT -# mkdir is atomic on every filesystem this runs on, which `[ -e ] && touch` is not. -waited=0 -while ! mkdir "$LOCK_DIR" 2>/dev/null; do - owner=$(cat "$LOCK_DIR/pid" 2>/dev/null || true) - if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then - # Do NOT rm -rf the observation directly: two builders can read the same dead pid, and - # the slower one would then delete the lock the faster one has already re-acquired, - # putting both inside the shared make clean/build this lock exists to prevent. Claim it - # by renaming instead - mv of a directory onto a non-existent name is atomic, so exactly - # one builder can succeed - and only delete what we ourselves took, after re-confirming - # the pid inside is still the dead one we saw. - claimed="$LOCK_DIR.stale.$$" - if mv "$LOCK_DIR" "$claimed" 2>/dev/null; then - if [ "$(cat "$claimed/pid" 2>/dev/null || true)" = "$owner" ]; then - echo "reclaimed a stale hev-socks5-tunnel build lock left by pid $owner" >&2 - rm -rf "$claimed" - else - # Somebody re-created it between our read and our rename; hand it back untouched. - mv "$claimed" "$LOCK_DIR" 2>/dev/null || rm -rf "$claimed" - fi - fi - continue - fi - if [ "$waited" -ge "$LOCK_WAIT_SECONDS" ]; then - echo "error: timed out after ${LOCK_WAIT_SECONDS}s waiting for $LOCK_DIR" >&2 - echo " remove it by hand if no other build is running" >&2 - exit 1 - fi - [ "$waited" = "0" ] && echo "another hev-socks5-tunnel build is running; waiting for it" - sleep 1 - waited=$((waited + 1)) -done -LOCK_HELD=1 -echo $$ > "$LOCK_DIR/pid" +# Keep one inode permanently and let the kernel release its advisory lock when this process exits. +# Unlike stale-directory reclamation, a waiter can never rename or remove another builder's live +# lock between observing and acquiring it. Descriptor 9 remains open for the rest of the script. +exec 9>"$LOCK_FILE" +if ! /usr/bin/lockf -t "$LOCK_WAIT_SECONDS" 9; then + echo "error: timed out after ${LOCK_WAIT_SECONDS}s waiting for $LOCK_FILE" >&2 + exit 1 +fi + +# The npm tarball ships the engine sources without Git metadata. `git -C` must not be allowed to +# walk up into the consuming application's repository, where every application commit would look +# like a new hev revision. Only trust Git when its top-level is the vendored engine itself; +# otherwise hash every shipped input while excluding the build products made by this script. +SUBMODULE_REALPATH="$(cd "$SUBMODULE_DIR" && pwd -P)" +SUBMODULE_GIT_TOPLEVEL="$(git -C "$SUBMODULE_DIR" rev-parse --show-toplevel 2>/dev/null || true)" +if [ -n "$SUBMODULE_GIT_TOPLEVEL" ] \ + && [ "$(cd "$SUBMODULE_GIT_TOPLEVEL" && pwd -P)" = "$SUBMODULE_REALPATH" ]; then + SUBMODULE_SHA=$(git -C "$SUBMODULE_DIR" rev-parse HEAD) + SOURCE_ID="$SUBMODULE_SHA" +else + SOURCE_ID=$( + cd "$SUBMODULE_DIR" + find . -name .git -prune -o -type f \ + ! -path './bin/*' ! -path './build/*' \ + ! -path './third-part/hev-task-system/bin/*' \ + ! -path './third-part/hev-task-system/build/*' \ + ! -path './third-part/lwip/bin/*' ! -path './third-part/lwip/build/*' \ + ! -path './third-part/yaml/bin/*' ! -path './third-part/yaml/build/*' \ + -print0 \ + | LC_ALL=C sort -z \ + | xargs -0 shasum -a 256 \ + | shasum -a 256 | cut -d' ' -f1 + ) + SUBMODULE_SHA="tree-$SOURCE_ID" +fi +STAMP="${SOURCE_ID}-${SCRIPT_SHA}-${MIN_IOS}" if [ "${1:-}" != "--force" ] && [ -d "$OUTPUT" ] && [ -f "$STAMP_FILE" ] \ && [ "$(cat "$STAMP_FILE")" = "$STAMP" ]; then diff --git a/Scripts/embed-tunnel-extension.sh b/Scripts/embed-tunnel-extension.sh index c2147753a6..f4f5eeb4b0 100755 --- a/Scripts/embed-tunnel-extension.sh +++ b/Scripts/embed-tunnel-extension.sh @@ -7,7 +7,7 @@ # cannot reach Runner.app/PlugIns through a regular embed build phase. # The extension target is built into BUILT_PRODUCTS_DIR via a target # dependency of WebDriverAgentRunner; this scheme post-action copies it -# into Runner.app/PlugIns, fixes its bundle id to match the host app +# into Runner.app/PlugIns, validates that its bundle id matches the host app # (extensions must be prefixed by the host's CFBundleIdentifier, which # Xcode suffixes with '.xctrunner') and re-signs inner-first. # @@ -44,22 +44,21 @@ rm -rf "$APPEX_DST" mkdir -p "$RUNNER_APP/PlugIns" cp -R "$APPEX_SRC" "$APPEX_DST" -# Extensions must carry a bundle id prefixed by the host app's. The host id is only final at -# this point (Xcode appends '.xctrunner'; downstream tooling may override the prefix), so -# always derive the appex id from the embedded Runner.app instead of trusting build settings. +# Extensions must be provisioned with their final bundle id. Rewriting it after Xcode signs the +# appex cannot update the embedded provisioning profile's application identifier, so fail the +# build instead of producing a bundle that installd will reject. HOST_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$RUNNER_APP/Info.plist") WANT_ID="${HOST_ID}.tunnel" CURRENT_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APPEX_DST/Info.plist") -ID_REWRITTEN=0 if [ "$CURRENT_ID" != "$WANT_ID" ]; then - /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $WANT_ID" "$APPEX_DST/Info.plist" - ID_REWRITTEN=1 + echo "error: $APPEX_NAME was built as '$CURRENT_ID', expected '$WANT_ID'." >&2 + echo "error: set WDA_PRODUCT_BUNDLE_IDENTIFIER to the runner's base bundle id before building." >&2 + exit 1 fi -# Re-codesign since we modified the bundle after Xcode signed it: the appex first (its bundle -# id may just have changed, so do NOT preserve the identifier), then the app so its seal covers -# the new nested code. In a scheme post-action context Xcode's CODE_SIGN_* env vars are not -# exposed, so discover the existing signing identity from the already-signed bundle. +# Re-codesign the copied appex first, then the app so its seal covers the new nested code. In a +# scheme post-action context Xcode's CODE_SIGN_* env vars are not exposed, so discover the +# existing signing identity from the already-signed bundle. if [ -d "$RUNNER_APP/_CodeSignature" ]; then # Capture the signature info once. Piping codesign straight into # `awk ... exit` makes awk close the pipe early, killing codesign with @@ -80,33 +79,8 @@ if [ -d "$RUNNER_APP/_CodeSignature" ]; then if ! codesign -d --entitlements - --xml "$APPEX_DST" > "$APPEX_ENTITLEMENTS_PLIST" 2>/dev/null; then : > "$APPEX_ENTITLEMENTS_PLIST" fi - if [ "$ID_REWRITTEN" = "1" ]; then - # The bundle id changed, so the signed entitlements' application-identifier must be - # regenerated to match it - preserving the stale entitlements makes installd reject - # the extension (identity no longer matches the bundle id). Rewrite the identifier in - # the extracted entitlements and re-sign with them. - if [ -s "$APPEX_ENTITLEMENTS_PLIST" ]; then - OLD_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :application-identifier" "$APPEX_ENTITLEMENTS_PLIST" 2>/dev/null || true) - TEAM_ID="${OLD_APP_ID%%.*}" - if [ -n "$TEAM_ID" ] && [ "$TEAM_ID" != "$OLD_APP_ID" ]; then - /usr/libexec/PlistBuddy -c "Set :application-identifier ${TEAM_ID}.${WANT_ID}" "$APPEX_ENTITLEMENTS_PLIST" - codesign --force --sign "$EXISTING_IDENT" \ - --entitlements "$APPEX_ENTITLEMENTS_PLIST" "$APPEX_DST" - else - # No application-identifier (e.g. simulator ad-hoc signing): nothing to fix up. - codesign --force --sign "$EXISTING_IDENT" \ - --preserve-metadata=entitlements "$APPEX_DST" - fi - else - codesign --force --sign "$EXISTING_IDENT" "$APPEX_DST" - fi - echo "warning: appex bundle id was rewritten to $WANT_ID; the embedded provisioning" \ - "profile must cover that id with the packet-tunnel-provider entitlement," \ - "otherwise re-sign with a matching profile - see docs/socks5-tunnel.md" - else - codesign --force --sign "$EXISTING_IDENT" \ - --preserve-metadata=entitlements "$APPEX_DST" - fi + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier,entitlements "$APPEX_DST" # Xcode is not documented to propagate the UI-test target's CODE_SIGN_ENTITLEMENTS onto # the generated Runner.app. The host must hold the NetworkExtension entitlement for diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index ad72c1fe2d..fe3c81ab0d 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -7,108 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 34AB13EFF1F673084C910195 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; - 718226CC2587443700661B83 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; - 718226CD2587443700661B83 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; - 718226CE2587443700661B83 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; - 718226CF2587443700661B83 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; - FBCAFE000000000000002002 /* FBImageUtilsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000002001 /* FBImageUtilsTests.m */; }; - FBCAFE000000000000001002 /* FBVideoStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001001 /* FBVideoStreamSession.h */; }; - FBCAFE000000000000001003 /* FBVideoStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001001 /* FBVideoStreamSession.h */; }; - FBCAFE000000000000001005 /* FBVideoStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001004 /* FBVideoStreamSession.m */; }; - FBCAFE000000000000001006 /* FBVideoStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001004 /* FBVideoStreamSession.m */; }; - FBCAFE000000000000000002 /* FBScreenCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */; }; - FBCAFE000000000000000003 /* FBScreenCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */; }; - FBCAFE000000000000000005 /* FBScreenCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */; }; - FBCAFE000000000000000006 /* FBScreenCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */; }; - FBCAFE000000000000003002 /* FBMobilerunA11yCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */; }; - FBCAFE000000000000003102 /* FBMobilerunActionsCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */; }; - FBCAFE000000000000003003 /* FBMobilerunA11yCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */; }; - FBCAFE000000000000003103 /* FBMobilerunActionsCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */; }; - FBCAFE000000000000003005 /* FBMobilerunA11yCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */; }; - FBCAFE000000000000003105 /* FBMobilerunActionsCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */; }; - FBCAFE000000000000003006 /* FBMobilerunA11yCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */; }; - FBCAFE000000000000003106 /* FBMobilerunActionsCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */; }; - FBCAFE000000000000000008 /* FBPixelBufferConverter.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000007 /* FBPixelBufferConverter.h */; }; - FBCAFE000000000000000009 /* FBPixelBufferConverter.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000007 /* FBPixelBufferConverter.h */; }; - FBCAFE00000000000000000B /* FBPixelBufferConverter.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000A /* FBPixelBufferConverter.m */; }; - FBCAFE00000000000000000C /* FBPixelBufferConverter.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000A /* FBPixelBufferConverter.m */; }; - FBCAFE00000000000000000E /* FBVideoEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000D /* FBVideoEncoder.h */; }; - FBCAFE00000000000000000F /* FBVideoEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000D /* FBVideoEncoder.h */; }; - FBCAFE000000000000000011 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; - FBCAFE000000000000000012 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; - FBCAFE000000000000000014 /* FBVideoStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000013 /* FBVideoStreamManager.h */; }; - FBCAFE000000000000000015 /* FBVideoStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000013 /* FBVideoStreamManager.h */; }; - FBCAFE000000000000000017 /* FBVideoStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000016 /* FBVideoStreamManager.m */; }; - FBCAFE000000000000000018 /* FBVideoStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000016 /* FBVideoStreamManager.m */; }; - FBCAFE00000000000000001A /* FBPixelBufferConverterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000019 /* FBPixelBufferConverterTests.m */; }; - FBCAFE00000000000000001C /* FBVideoEncoderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000001B /* FBVideoEncoderTests.m */; }; - FBCAFE000000000000004002 /* FBVideoStreamSessionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */; }; - FBCAFE000000000000005103 /* FBBroadcastSampleHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */; }; - FBCAFE000000000000005106 /* FBExtBroadcastClient.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005105 /* FBExtBroadcastClient.m */; }; - FBCAFE000000000000005109 /* FBExtSessionPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005108 /* FBExtSessionPipeline.m */; }; - FBCAFE000000000000005201 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; - FBCAFE000000000000005202 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; - FBCAFE000000000000005203 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; - FBCAFE000000000000007006 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; - FBCAFE000000000000007112 /* FBTunnelPacketProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */; }; - FBCAFE000000000000007115 /* FBTunFdFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007114 /* FBTunFdFinder.m */; }; - FBCAFE000000000000007118 /* FBTunnelHevRunner.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007117 /* FBTunnelHevRunner.m */; }; - FBCAFE000000000000007121 /* HevSocks5Tunnel.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */; }; - FBCAFE000000000000005302 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; - FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; - FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; - FBCAFE000000000000005305 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; - FBCAFE000000000000007002 /* FBSocks5TunnelProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */; }; - FBCAFE000000000000007003 /* FBSocks5TunnelProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */; }; - FBCAFE000000000000007004 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; - FBCAFE000000000000007005 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; - FBCAFE000000000000007012 /* FBSocks5URI.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007010 /* FBSocks5URI.h */; }; - FBCAFE000000000000007013 /* FBSocks5URI.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007010 /* FBSocks5URI.h */; }; - FBCAFE000000000000007014 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; - FBCAFE000000000000007015 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; - FBCAFE000000000000007021 /* FBSocks5URITests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007020 /* FBSocks5URITests.m */; }; - FBCAFE000000000000007023 /* FBSocks5ConfigTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */; }; - FBCAFE000000000000007032 /* FBSocks5TunnelManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */; }; - FBCAFE000000000000007033 /* FBSocks5TunnelManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */; }; - FBCAFE000000000000007034 /* FBSocks5TunnelManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */; }; - FBCAFE000000000000007035 /* FBSocks5TunnelManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */; }; - FBCAFE000000000000007042 /* FBMobilerunSocks5Commands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */; }; - FBCAFE000000000000007043 /* FBMobilerunSocks5Commands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */; }; - FBCAFE000000000000007044 /* FBMobilerunSocks5Commands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */; }; - FBCAFE000000000000007045 /* FBMobilerunSocks5Commands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */; }; - FBCAFE000000000000007331 /* FBMobilerunSocks5IntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */; }; - FBCAFE000000000000005306 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; - FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; - FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; - FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; - FBCAFE000000000000005315 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; - FBCAFE000000000000005322 /* FBBroadcastManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005320 /* FBBroadcastManager.h */; }; - FBCAFE000000000000005323 /* FBBroadcastManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005320 /* FBBroadcastManager.h */; }; - FBCAFE000000000000005324 /* FBBroadcastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005321 /* FBBroadcastManager.m */; }; - FBCAFE000000000000005325 /* FBBroadcastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005321 /* FBBroadcastManager.m */; }; - FBCAFE000000000000005332 /* FBBroadcastPickerHost.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */; }; - FBCAFE000000000000005333 /* FBBroadcastPickerHost.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */; }; - FBCAFE000000000000005334 /* FBBroadcastPickerHost.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */; }; - FBCAFE000000000000005335 /* FBBroadcastPickerHost.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */; }; - FBCAFE000000000000006002 /* FBScrcpyPacket.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006000 /* FBScrcpyPacket.h */; }; - FBCAFE000000000000006003 /* FBScrcpyPacket.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006000 /* FBScrcpyPacket.h */; }; - FBCAFE000000000000006004 /* FBScrcpyPacket.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006001 /* FBScrcpyPacket.m */; }; - FBCAFE000000000000006005 /* FBScrcpyPacket.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006001 /* FBScrcpyPacket.m */; }; - FBCAFE000000000000006012 /* FBAudioStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006010 /* FBAudioStreamSession.h */; }; - FBCAFE000000000000006013 /* FBAudioStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006010 /* FBAudioStreamSession.h */; }; - FBCAFE000000000000006014 /* FBAudioStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006011 /* FBAudioStreamSession.m */; }; - FBCAFE000000000000006015 /* FBAudioStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006011 /* FBAudioStreamSession.m */; }; - FBCAFE000000000000006022 /* FBAudioStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006020 /* FBAudioStreamManager.h */; }; - FBCAFE000000000000006023 /* FBAudioStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006020 /* FBAudioStreamManager.h */; }; - FBCAFE000000000000006024 /* FBAudioStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006021 /* FBAudioStreamManager.m */; }; - FBCAFE000000000000006025 /* FBAudioStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006021 /* FBAudioStreamManager.m */; }; - FBCAFE000000000000006032 /* FBAudioCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */; }; - FBCAFE000000000000006033 /* FBAudioCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */; }; - FBCAFE000000000000006034 /* FBAudioCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */; }; - FBCAFE000000000000006035 /* FBAudioCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */; }; - FBCAFE000000000000006042 /* FBExtAudioPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006041 /* FBExtAudioPipeline.m */; }; - FBCAFE000000000000006051 /* FBAudioStreamTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006050 /* FBAudioStreamTests.m */; }; 005B327C702EF47820887884 /* FBErrorBuilder.h in Headers */ = {isa = PBXBuildFile; fileRef = EE3A18601CDE618F00DE4205 /* FBErrorBuilder.h */; }; 00ABDDC324B060006EA182F7 /* FBScreen.m in Sources */ = {isa = PBXBuildFile; fileRef = 715AFAC01FFA29180053896D /* FBScreen.m */; }; 0161F45997A981E47729DB25 /* RouteResponse.h in Headers */ = {isa = PBXBuildFile; fileRef = 01E7CFEBAD717FCF4BCDD383 /* RouteResponse.h */; }; @@ -266,6 +164,7 @@ 3400BE6CBA163DC9A58D60E6 /* XCUIApplicationProcessTracker-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 3238E68F292452D8234153F1 /* XCUIApplicationProcessTracker-Protocol.h */; }; 348B7FFB742C26599E44DB67 /* XCTMessagingRole_ProcessMonitoring-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = B43D656732067585371ADD31 /* XCTMessagingRole_ProcessMonitoring-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34A32D91F0C8B6A31A9E8F9A /* FBSettingsHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F3E7D725417FF400E0C22C /* FBSettingsHandler.m */; }; + 34AB13EFF1F673084C910195 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; 34E3403E0FE0E93C74E8FB2D /* XCTScreenCapturePolicy.h in Headers */ = {isa = PBXBuildFile; fileRef = FDB15E393EA0850C004D26B2 /* XCTScreenCapturePolicy.h */; }; 35924251B4B5D0A486A6A0BB /* FBHTTPServer.m in Sources */ = {isa = PBXBuildFile; fileRef = AADFEA2ED9E61A8C1A99B2D7 /* FBHTTPServer.m */; }; 35C087E005C82F9642F8A76C /* XCUIDeviceDelayedAttachmentTransferSupportInterface-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 4C19CEEEC9858A106061D1F8 /* XCUIDeviceDelayedAttachmentTransferSupportInterface-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -623,7 +522,6 @@ 71241D7B1FAE3D2500B9559F /* FBTouchActionCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = 71241D791FAE3D2500B9559F /* FBTouchActionCommands.h */; }; 71241D7C1FAE3D2500B9559F /* FBTouchActionCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = 71241D7A1FAE3D2500B9559F /* FBTouchActionCommands.m */; }; 71241D7E1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 71241D7D1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m */; }; - FBCAFE000000000000003202 /* FBMobilerunActionsIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */; }; 71241D801FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 71241D7F1FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m */; }; 712A0C851DA3E459007D02E5 /* FBXPathTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 712A0C841DA3E459007D02E5 /* FBXPathTests.m */; }; 712A0C871DA3E55D007D02E5 /* FBXPath-Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 712A0C861DA3E55D007D02E5 /* FBXPath-Private.h */; }; @@ -707,6 +605,10 @@ 716F0DA12A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h in Headers */ = {isa = PBXBuildFile; fileRef = 716F0D9F2A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h */; }; 716F0DA32A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m in Sources */ = {isa = PBXBuildFile; fileRef = 716F0DA02A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m */; }; 716F0DA62A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */; }; + 718226CC2587443700661B83 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; + 718226CD2587443700661B83 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; + 718226CE2587443700661B83 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; + 718226CF2587443700661B83 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; 7182A87F3CAA27F71B624AD2 /* XCTRunnerAutomationSession-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 01AF8E73DD47455B4854E470 /* XCTRunnerAutomationSession-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 718F49C8230844330045FE8B /* FBProtocolHelpersTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */; }; 718F49C923087ACF0045FE8B /* FBProtocolHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 71B155DD23080CA600646AFB /* FBProtocolHelpers.h */; }; @@ -1292,7 +1194,6 @@ EE8DDD7F20C5733C004D4925 /* XCUIElement+FBForceTouch.h in Headers */ = {isa = PBXBuildFile; fileRef = EE8DDD7D20C5733C004D4925 /* XCUIElement+FBForceTouch.h */; settings = {ATTRIBUTES = (Public, ); }; }; EE9AB8011CAEE048008C271F /* UITestingUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */; }; EE9B76591CF7987800275851 /* FBRouteTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76571CF7987300275851 /* FBRouteTests.m */; }; - FBCAFE000000000000003011 /* FBTestInterruptionSuppressionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */; }; EE9B768E1CF7997600275851 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76831CF7997600275851 /* AppDelegate.m */; }; EE9B768F1CF7997600275851 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76851CF7997600275851 /* ViewController.m */; }; EE9B76911CF7997600275851 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76871CF7997600275851 /* main.m */; }; @@ -1356,6 +1257,105 @@ FAC85D261CAD26DB5F9D804C /* XCUIPlatformApplicationServicesProviding-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 3BA71BCF1FDA482419CA8596 /* XCUIPlatformApplicationServicesProviding-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; FBAFC553152CE132D6CB98E5 /* XCUIDeviceDelayedAttachmentTransferSupportInterface-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 4C19CEEEC9858A106061D1F8 /* XCUIDeviceDelayedAttachmentTransferSupportInterface-Protocol.h */; }; FBB82323D9D57F069440BF94 /* XCTMessagingRole_SystemConfiguration-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = EA24F4BE52B2520874E09BEF /* XCTMessagingRole_SystemConfiguration-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FBCAFE000000000000000002 /* FBScreenCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */; }; + FBCAFE000000000000000003 /* FBScreenCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */; }; + FBCAFE000000000000000005 /* FBScreenCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */; }; + FBCAFE000000000000000006 /* FBScreenCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */; }; + FBCAFE000000000000000008 /* FBPixelBufferConverter.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000007 /* FBPixelBufferConverter.h */; }; + FBCAFE000000000000000009 /* FBPixelBufferConverter.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000007 /* FBPixelBufferConverter.h */; }; + FBCAFE00000000000000000B /* FBPixelBufferConverter.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000A /* FBPixelBufferConverter.m */; }; + FBCAFE00000000000000000C /* FBPixelBufferConverter.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000A /* FBPixelBufferConverter.m */; }; + FBCAFE00000000000000000E /* FBVideoEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000D /* FBVideoEncoder.h */; }; + FBCAFE00000000000000000F /* FBVideoEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000D /* FBVideoEncoder.h */; }; + FBCAFE000000000000000011 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; + FBCAFE000000000000000012 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; + FBCAFE000000000000000014 /* FBVideoStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000013 /* FBVideoStreamManager.h */; }; + FBCAFE000000000000000015 /* FBVideoStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000013 /* FBVideoStreamManager.h */; }; + FBCAFE000000000000000017 /* FBVideoStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000016 /* FBVideoStreamManager.m */; }; + FBCAFE000000000000000018 /* FBVideoStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000016 /* FBVideoStreamManager.m */; }; + FBCAFE00000000000000001A /* FBPixelBufferConverterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000019 /* FBPixelBufferConverterTests.m */; }; + FBCAFE00000000000000001C /* FBVideoEncoderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000001B /* FBVideoEncoderTests.m */; }; + FBCAFE000000000000001002 /* FBVideoStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001001 /* FBVideoStreamSession.h */; }; + FBCAFE000000000000001003 /* FBVideoStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001001 /* FBVideoStreamSession.h */; }; + FBCAFE000000000000001005 /* FBVideoStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001004 /* FBVideoStreamSession.m */; }; + FBCAFE000000000000001006 /* FBVideoStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001004 /* FBVideoStreamSession.m */; }; + FBCAFE000000000000002002 /* FBImageUtilsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000002001 /* FBImageUtilsTests.m */; }; + FBCAFE000000000000003002 /* FBMobilerunA11yCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */; }; + FBCAFE000000000000003003 /* FBMobilerunA11yCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */; }; + FBCAFE000000000000003005 /* FBMobilerunA11yCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */; }; + FBCAFE000000000000003006 /* FBMobilerunA11yCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */; }; + FBCAFE000000000000003011 /* FBTestInterruptionSuppressionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */; }; + FBCAFE000000000000003102 /* FBMobilerunActionsCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */; }; + FBCAFE000000000000003103 /* FBMobilerunActionsCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */; }; + FBCAFE000000000000003105 /* FBMobilerunActionsCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */; }; + FBCAFE000000000000003106 /* FBMobilerunActionsCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */; }; + FBCAFE000000000000003202 /* FBMobilerunActionsIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */; }; + FBCAFE000000000000004002 /* FBVideoStreamSessionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */; }; + FBCAFE000000000000005103 /* FBBroadcastSampleHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */; }; + FBCAFE000000000000005106 /* FBExtBroadcastClient.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005105 /* FBExtBroadcastClient.m */; }; + FBCAFE000000000000005109 /* FBExtSessionPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005108 /* FBExtSessionPipeline.m */; }; + FBCAFE000000000000005201 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; + FBCAFE000000000000005202 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; + FBCAFE000000000000005203 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000005302 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; + FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; + FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000005305 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000005306 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; + FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; + FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; + FBCAFE000000000000005315 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; + FBCAFE000000000000005322 /* FBBroadcastManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005320 /* FBBroadcastManager.h */; }; + FBCAFE000000000000005323 /* FBBroadcastManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005320 /* FBBroadcastManager.h */; }; + FBCAFE000000000000005324 /* FBBroadcastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005321 /* FBBroadcastManager.m */; }; + FBCAFE000000000000005325 /* FBBroadcastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005321 /* FBBroadcastManager.m */; }; + FBCAFE000000000000005332 /* FBBroadcastPickerHost.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */; }; + FBCAFE000000000000005333 /* FBBroadcastPickerHost.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */; }; + FBCAFE000000000000005334 /* FBBroadcastPickerHost.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */; }; + FBCAFE000000000000005335 /* FBBroadcastPickerHost.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */; }; + FBCAFE000000000000006002 /* FBScrcpyPacket.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006000 /* FBScrcpyPacket.h */; }; + FBCAFE000000000000006003 /* FBScrcpyPacket.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006000 /* FBScrcpyPacket.h */; }; + FBCAFE000000000000006004 /* FBScrcpyPacket.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006001 /* FBScrcpyPacket.m */; }; + FBCAFE000000000000006005 /* FBScrcpyPacket.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006001 /* FBScrcpyPacket.m */; }; + FBCAFE000000000000006012 /* FBAudioStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006010 /* FBAudioStreamSession.h */; }; + FBCAFE000000000000006013 /* FBAudioStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006010 /* FBAudioStreamSession.h */; }; + FBCAFE000000000000006014 /* FBAudioStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006011 /* FBAudioStreamSession.m */; }; + FBCAFE000000000000006015 /* FBAudioStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006011 /* FBAudioStreamSession.m */; }; + FBCAFE000000000000006022 /* FBAudioStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006020 /* FBAudioStreamManager.h */; }; + FBCAFE000000000000006023 /* FBAudioStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006020 /* FBAudioStreamManager.h */; }; + FBCAFE000000000000006024 /* FBAudioStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006021 /* FBAudioStreamManager.m */; }; + FBCAFE000000000000006025 /* FBAudioStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006021 /* FBAudioStreamManager.m */; }; + FBCAFE000000000000006032 /* FBAudioCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */; }; + FBCAFE000000000000006033 /* FBAudioCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */; }; + FBCAFE000000000000006034 /* FBAudioCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */; }; + FBCAFE000000000000006035 /* FBAudioCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */; }; + FBCAFE000000000000006042 /* FBExtAudioPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006041 /* FBExtAudioPipeline.m */; }; + FBCAFE000000000000006051 /* FBAudioStreamTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006050 /* FBAudioStreamTests.m */; }; + FBCAFE000000000000007002 /* FBSocks5TunnelProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */; }; + FBCAFE000000000000007003 /* FBSocks5TunnelProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */; }; + FBCAFE000000000000007004 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007005 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007006 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007012 /* FBSocks5URI.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007010 /* FBSocks5URI.h */; }; + FBCAFE000000000000007013 /* FBSocks5URI.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007010 /* FBSocks5URI.h */; }; + FBCAFE000000000000007014 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; + FBCAFE000000000000007015 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; + FBCAFE000000000000007021 /* FBSocks5URITests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007020 /* FBSocks5URITests.m */; }; + FBCAFE000000000000007023 /* FBSocks5ConfigTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */; }; + FBCAFE000000000000007032 /* FBSocks5TunnelManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */; }; + FBCAFE000000000000007033 /* FBSocks5TunnelManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */; }; + FBCAFE000000000000007034 /* FBSocks5TunnelManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */; }; + FBCAFE000000000000007035 /* FBSocks5TunnelManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */; }; + FBCAFE000000000000007042 /* FBMobilerunSocks5Commands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */; }; + FBCAFE000000000000007043 /* FBMobilerunSocks5Commands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */; }; + FBCAFE000000000000007044 /* FBMobilerunSocks5Commands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */; }; + FBCAFE000000000000007045 /* FBMobilerunSocks5Commands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */; }; + FBCAFE000000000000007112 /* FBTunnelPacketProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */; }; + FBCAFE000000000000007115 /* FBTunFdFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007114 /* FBTunFdFinder.m */; }; + FBCAFE000000000000007118 /* FBTunnelHevRunner.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007117 /* FBTunnelHevRunner.m */; }; + FBCAFE000000000000007121 /* HevSocks5Tunnel.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */; }; + FBCAFE000000000000007331 /* FBMobilerunSocks5IntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */; }; FBF064E4D96CFCD08E1F4EF7 /* XCUIDevice.h in Headers */ = {isa = PBXBuildFile; fileRef = EE35ACFD1E3B77D600A02D78 /* XCUIDevice.h */; }; FBFEC05ED2C01D1EAE34BE9A /* WDAScreenshotAndSourceIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F677B5B7737E7E1F321C20 /* WDAScreenshotAndSourceIntegrationTests.swift */; }; FC607DB5132FEF425237267B /* XCTMessagingRole_MemoryTesting-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 13D863EAE7F6F8B7E42D99B9 /* XCTMessagingRole_MemoryTesting-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1522,78 +1522,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 718226C62587443600661B83 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = GCDAsyncUdpSocket.h; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.h; sourceTree = SOURCE_ROOT; }; - 718226C72587443600661B83 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = GCDAsyncSocket.h; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.h; sourceTree = SOURCE_ROOT; }; - 718226C82587443600661B83 /* GCDAsyncSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = GCDAsyncSocket.m; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.m; sourceTree = SOURCE_ROOT; }; - 718226C92587443600661B83 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = GCDAsyncUdpSocket.m; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.m; sourceTree = SOURCE_ROOT; }; - FBCAFE000000000000002001 /* FBImageUtilsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBImageUtilsTests.m; sourceTree = ""; }; - FBCAFE000000000000001001 /* FBVideoStreamSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoStreamSession.h; sourceTree = ""; }; - FBCAFE000000000000001004 /* FBVideoStreamSession.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSession.m; sourceTree = ""; }; - FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBScreenCaptureCommands.h; sourceTree = ""; }; - FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBScreenCaptureCommands.m; sourceTree = ""; }; - FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunA11yCommands.h; sourceTree = ""; }; - FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunActionsCommands.h; sourceTree = ""; }; - FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunA11yCommands.m; sourceTree = ""; }; - FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunActionsCommands.m; sourceTree = ""; }; - FBCAFE000000000000000007 /* FBPixelBufferConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBPixelBufferConverter.h; sourceTree = ""; }; - FBCAFE00000000000000000A /* FBPixelBufferConverter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBPixelBufferConverter.m; sourceTree = ""; }; - FBCAFE00000000000000000D /* FBVideoEncoder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoEncoder.h; sourceTree = ""; }; - FBCAFE000000000000000010 /* FBVideoEncoder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoEncoder.m; sourceTree = ""; }; - FBCAFE000000000000000013 /* FBVideoStreamManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoStreamManager.h; sourceTree = ""; }; - FBCAFE000000000000000016 /* FBVideoStreamManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamManager.m; sourceTree = ""; }; - FBCAFE000000000000000019 /* FBPixelBufferConverterTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBPixelBufferConverterTests.m; sourceTree = ""; }; - FBCAFE00000000000000001B /* FBVideoEncoderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoEncoderTests.m; sourceTree = ""; }; - FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSessionTests.m; sourceTree = ""; }; - FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - FBCAFE00000000000000500C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - FBCAFE000000000000007102 /* WebDriverAgentTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - FBCAFE00000000000000710C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = WebDriverAgentTunnel/Info.plist; sourceTree = SOURCE_ROOT; }; - FBCAFE000000000000007110 /* FBTunnelPacketProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunnelPacketProvider.h; sourceTree = ""; }; - FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunnelPacketProvider.m; sourceTree = ""; }; - FBCAFE000000000000007113 /* FBTunFdFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunFdFinder.h; sourceTree = ""; }; - FBCAFE000000000000007114 /* FBTunFdFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunFdFinder.m; sourceTree = ""; }; - FBCAFE000000000000007116 /* FBTunnelHevRunner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunnelHevRunner.h; sourceTree = ""; }; - FBCAFE000000000000007117 /* FBTunnelHevRunner.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunnelHevRunner.m; sourceTree = ""; }; - FBCAFE000000000000007119 /* WebDriverAgentTunnel.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = WebDriverAgentTunnel.entitlements; sourceTree = ""; }; - FBCAFE00000000000000711A /* WebDriverAgentRunner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = WebDriverAgentRunner.entitlements; path = WebDriverAgentRunner/WebDriverAgentRunner.entitlements; sourceTree = SOURCE_ROOT; }; - FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = HevSocks5Tunnel.xcframework; path = ThirdParty/HevSocks5Tunnel.xcframework; sourceTree = SOURCE_ROOT; }; - FBCAFE000000000000005101 /* FBBroadcastSampleHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastSampleHandler.h; sourceTree = ""; }; - FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastSampleHandler.m; sourceTree = ""; }; - FBCAFE000000000000005104 /* FBExtBroadcastClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtBroadcastClient.h; sourceTree = ""; }; - FBCAFE000000000000005105 /* FBExtBroadcastClient.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtBroadcastClient.m; sourceTree = ""; }; - FBCAFE000000000000005107 /* FBExtSessionPipeline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtSessionPipeline.h; sourceTree = ""; }; - FBCAFE000000000000005108 /* FBExtSessionPipeline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtSessionPipeline.m; sourceTree = ""; }; - FBCAFE000000000000006000 /* FBScrcpyPacket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBScrcpyPacket.h; sourceTree = ""; }; - FBCAFE000000000000006001 /* FBScrcpyPacket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBScrcpyPacket.m; sourceTree = ""; }; - FBCAFE000000000000006010 /* FBAudioStreamSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioStreamSession.h; sourceTree = ""; }; - FBCAFE000000000000006011 /* FBAudioStreamSession.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamSession.m; sourceTree = ""; }; - FBCAFE000000000000006020 /* FBAudioStreamManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioStreamManager.h; sourceTree = ""; }; - FBCAFE000000000000006021 /* FBAudioStreamManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamManager.m; sourceTree = ""; }; - FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioCaptureCommands.h; sourceTree = ""; }; - FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioCaptureCommands.m; sourceTree = ""; }; - FBCAFE000000000000006040 /* FBExtAudioPipeline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtAudioPipeline.h; sourceTree = ""; }; - FBCAFE000000000000006041 /* FBExtAudioPipeline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtAudioPipeline.m; sourceTree = ""; }; - FBCAFE000000000000006050 /* FBAudioStreamTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamTests.m; sourceTree = ""; }; - FBCAFE00000000000000510A /* FBExtLogging.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtLogging.h; sourceTree = ""; }; - FBCAFE000000000000005300 /* FBBroadcastProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastProtocol.h; sourceTree = ""; }; - FBCAFE000000000000005301 /* FBBroadcastProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastProtocol.m; sourceTree = ""; }; - FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5TunnelProtocol.h; sourceTree = ""; }; - FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5TunnelProtocol.m; sourceTree = ""; }; - FBCAFE000000000000007010 /* FBSocks5URI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5URI.h; sourceTree = ""; }; - FBCAFE000000000000007011 /* FBSocks5URI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URI.m; sourceTree = ""; }; - FBCAFE000000000000007020 /* FBSocks5URITests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URITests.m; sourceTree = ""; }; - FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5ConfigTests.m; sourceTree = ""; }; - FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5TunnelManager.h; sourceTree = ""; }; - FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5TunnelManager.m; sourceTree = ""; }; - FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunSocks5Commands.h; sourceTree = ""; }; - FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunSocks5Commands.m; sourceTree = ""; }; - FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunSocks5IntegrationTests.m; sourceTree = ""; }; - FBCAFE000000000000005310 /* FBBroadcastControlServer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastControlServer.h; sourceTree = ""; }; - FBCAFE000000000000005311 /* FBBroadcastControlServer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastControlServer.m; sourceTree = ""; }; - FBCAFE000000000000005320 /* FBBroadcastManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastManager.h; sourceTree = ""; }; - FBCAFE000000000000005321 /* FBBroadcastManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastManager.m; sourceTree = ""; }; - FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastPickerHost.h; sourceTree = ""; }; - FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastPickerHost.m; sourceTree = ""; }; 003AAAB9CB5FB38E45E05F6F /* XCUIDeviceEventAndStateInterface-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIDeviceEventAndStateInterface-Protocol.h"; sourceTree = ""; }; 00834B3220005AD5A5ABEF7C /* XCUIApplicationManaging-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIApplicationManaging-Protocol.h"; sourceTree = ""; }; 00B1F89716AFE04C8509B916 /* FBHTTPServer.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = FBHTTPServer.h; sourceTree = ""; }; @@ -1713,7 +1641,6 @@ 71241D791FAE3D2500B9559F /* FBTouchActionCommands.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBTouchActionCommands.h; sourceTree = ""; }; 71241D7A1FAE3D2500B9559F /* FBTouchActionCommands.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBTouchActionCommands.m; sourceTree = ""; }; 71241D7D1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBW3CTouchActionsIntegrationTests.m; sourceTree = ""; }; - FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunActionsIntegrationTests.m; sourceTree = ""; }; 71241D7F1FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBW3CMultiTouchActionsIntegrationTests.m; sourceTree = ""; }; 712A0C841DA3E459007D02E5 /* FBXPathTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBXPathTests.m; sourceTree = ""; }; 712A0C861DA3E55D007D02E5 /* FBXPath-Private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "FBXPath-Private.h"; sourceTree = ""; }; @@ -1781,6 +1708,10 @@ 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSDictionaryFBUtf8SafeTests.m; sourceTree = ""; }; 717C0D702518ED2800CAA6EC /* TVOSSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TVOSSettings.xcconfig; sourceTree = ""; }; 717C0D862518ED7000CAA6EC /* TVOSTestSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TVOSTestSettings.xcconfig; sourceTree = ""; }; + 718226C62587443600661B83 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = GCDAsyncUdpSocket.h; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.h; sourceTree = SOURCE_ROOT; }; + 718226C72587443600661B83 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = GCDAsyncSocket.h; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.h; sourceTree = SOURCE_ROOT; }; + 718226C82587443600661B83 /* GCDAsyncSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = GCDAsyncSocket.m; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.m; sourceTree = SOURCE_ROOT; }; + 718226C92587443600661B83 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = GCDAsyncUdpSocket.m; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.m; sourceTree = SOURCE_ROOT; }; 7183E8C2B556594311CB8898 /* XCUIRemoteSiriInterface-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIRemoteSiriInterface-Protocol.h"; sourceTree = ""; }; 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBProtocolHelpersTests.m; sourceTree = ""; }; 71930C4020662E1F00D3AFEC /* FBPasteboard.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBPasteboard.h; sourceTree = ""; }; @@ -2087,7 +2018,6 @@ EE9B75D41CF7956C00275851 /* IntegrationApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IntegrationApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B75EC1CF7956C00275851 /* IntegrationTests_1.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests_1.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B76571CF7987300275851 /* FBRouteTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBRouteTests.m; sourceTree = ""; }; - FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBTestInterruptionSuppressionTests.m; sourceTree = ""; }; EE9B76581CF7987300275851 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; EE9B76821CF7997600275851 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; EE9B76831CF7997600275851 /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -2131,6 +2061,76 @@ F46F78706C5157469122F730 /* FBXCElementSnapshotDouble.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBXCElementSnapshotDouble.m; sourceTree = ""; }; F59CD6D22EF16E5E00F91287 /* XCUIElement+FBCustomActions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "XCUIElement+FBCustomActions.h"; sourceTree = ""; }; F59CD6D32EF16E5E00F91287 /* XCUIElement+FBCustomActions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "XCUIElement+FBCustomActions.m"; sourceTree = ""; }; + FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBScreenCaptureCommands.h; sourceTree = ""; }; + FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBScreenCaptureCommands.m; sourceTree = ""; }; + FBCAFE000000000000000007 /* FBPixelBufferConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBPixelBufferConverter.h; sourceTree = ""; }; + FBCAFE00000000000000000A /* FBPixelBufferConverter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBPixelBufferConverter.m; sourceTree = ""; }; + FBCAFE00000000000000000D /* FBVideoEncoder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoEncoder.h; sourceTree = ""; }; + FBCAFE000000000000000010 /* FBVideoEncoder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoEncoder.m; sourceTree = ""; }; + FBCAFE000000000000000013 /* FBVideoStreamManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoStreamManager.h; sourceTree = ""; }; + FBCAFE000000000000000016 /* FBVideoStreamManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamManager.m; sourceTree = ""; }; + FBCAFE000000000000000019 /* FBPixelBufferConverterTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBPixelBufferConverterTests.m; sourceTree = ""; }; + FBCAFE00000000000000001B /* FBVideoEncoderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoEncoderTests.m; sourceTree = ""; }; + FBCAFE000000000000001001 /* FBVideoStreamSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoStreamSession.h; sourceTree = ""; }; + FBCAFE000000000000001004 /* FBVideoStreamSession.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSession.m; sourceTree = ""; }; + FBCAFE000000000000002001 /* FBImageUtilsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBImageUtilsTests.m; sourceTree = ""; }; + FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunA11yCommands.h; sourceTree = ""; }; + FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunA11yCommands.m; sourceTree = ""; }; + FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBTestInterruptionSuppressionTests.m; sourceTree = ""; }; + FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunActionsCommands.h; sourceTree = ""; }; + FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunActionsCommands.m; sourceTree = ""; }; + FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunActionsIntegrationTests.m; sourceTree = ""; }; + FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSessionTests.m; sourceTree = ""; }; + FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + FBCAFE00000000000000500C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FBCAFE000000000000005101 /* FBBroadcastSampleHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastSampleHandler.h; sourceTree = ""; }; + FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastSampleHandler.m; sourceTree = ""; }; + FBCAFE000000000000005104 /* FBExtBroadcastClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtBroadcastClient.h; sourceTree = ""; }; + FBCAFE000000000000005105 /* FBExtBroadcastClient.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtBroadcastClient.m; sourceTree = ""; }; + FBCAFE000000000000005107 /* FBExtSessionPipeline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtSessionPipeline.h; sourceTree = ""; }; + FBCAFE000000000000005108 /* FBExtSessionPipeline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtSessionPipeline.m; sourceTree = ""; }; + FBCAFE00000000000000510A /* FBExtLogging.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtLogging.h; sourceTree = ""; }; + FBCAFE000000000000005300 /* FBBroadcastProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastProtocol.h; sourceTree = ""; }; + FBCAFE000000000000005301 /* FBBroadcastProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastProtocol.m; sourceTree = ""; }; + FBCAFE000000000000005310 /* FBBroadcastControlServer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastControlServer.h; sourceTree = ""; }; + FBCAFE000000000000005311 /* FBBroadcastControlServer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastControlServer.m; sourceTree = ""; }; + FBCAFE000000000000005320 /* FBBroadcastManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastManager.h; sourceTree = ""; }; + FBCAFE000000000000005321 /* FBBroadcastManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastManager.m; sourceTree = ""; }; + FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastPickerHost.h; sourceTree = ""; }; + FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastPickerHost.m; sourceTree = ""; }; + FBCAFE000000000000006000 /* FBScrcpyPacket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBScrcpyPacket.h; sourceTree = ""; }; + FBCAFE000000000000006001 /* FBScrcpyPacket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBScrcpyPacket.m; sourceTree = ""; }; + FBCAFE000000000000006010 /* FBAudioStreamSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioStreamSession.h; sourceTree = ""; }; + FBCAFE000000000000006011 /* FBAudioStreamSession.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamSession.m; sourceTree = ""; }; + FBCAFE000000000000006020 /* FBAudioStreamManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioStreamManager.h; sourceTree = ""; }; + FBCAFE000000000000006021 /* FBAudioStreamManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamManager.m; sourceTree = ""; }; + FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioCaptureCommands.h; sourceTree = ""; }; + FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioCaptureCommands.m; sourceTree = ""; }; + FBCAFE000000000000006040 /* FBExtAudioPipeline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtAudioPipeline.h; sourceTree = ""; }; + FBCAFE000000000000006041 /* FBExtAudioPipeline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtAudioPipeline.m; sourceTree = ""; }; + FBCAFE000000000000006050 /* FBAudioStreamTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamTests.m; sourceTree = ""; }; + FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5TunnelProtocol.h; sourceTree = ""; }; + FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5TunnelProtocol.m; sourceTree = ""; }; + FBCAFE000000000000007010 /* FBSocks5URI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5URI.h; sourceTree = ""; }; + FBCAFE000000000000007011 /* FBSocks5URI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URI.m; sourceTree = ""; }; + FBCAFE000000000000007020 /* FBSocks5URITests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URITests.m; sourceTree = ""; }; + FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5ConfigTests.m; sourceTree = ""; }; + FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5TunnelManager.h; sourceTree = ""; }; + FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5TunnelManager.m; sourceTree = ""; }; + FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunSocks5Commands.h; sourceTree = ""; }; + FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunSocks5Commands.m; sourceTree = ""; }; + FBCAFE000000000000007102 /* WebDriverAgentTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + FBCAFE00000000000000710C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = WebDriverAgentTunnel/Info.plist; sourceTree = SOURCE_ROOT; }; + FBCAFE000000000000007110 /* FBTunnelPacketProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunnelPacketProvider.h; sourceTree = ""; }; + FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunnelPacketProvider.m; sourceTree = ""; }; + FBCAFE000000000000007113 /* FBTunFdFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunFdFinder.h; sourceTree = ""; }; + FBCAFE000000000000007114 /* FBTunFdFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunFdFinder.m; sourceTree = ""; }; + FBCAFE000000000000007116 /* FBTunnelHevRunner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunnelHevRunner.h; sourceTree = ""; }; + FBCAFE000000000000007117 /* FBTunnelHevRunner.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunnelHevRunner.m; sourceTree = ""; }; + FBCAFE000000000000007119 /* WebDriverAgentTunnel.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = WebDriverAgentTunnel.entitlements; sourceTree = ""; }; + FBCAFE00000000000000711A /* WebDriverAgentRunner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = WebDriverAgentRunner.entitlements; path = WebDriverAgentRunner/WebDriverAgentRunner.entitlements; sourceTree = SOURCE_ROOT; }; + FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = HevSocks5Tunnel.xcframework; path = ThirdParty/HevSocks5Tunnel.xcframework; sourceTree = SOURCE_ROOT; }; + FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunSocks5IntegrationTests.m; sourceTree = ""; }; FCD1815F2BF21CA0936B04E1 /* XCTMessagingRole_SiriAutomation-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTMessagingRole_SiriAutomation-Protocol.h"; sourceTree = ""; }; FDB15E393EA0850C004D26B2 /* XCTScreenCapturePolicy.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTScreenCapturePolicy.h; sourceTree = ""; }; FF8E3B470FC9639D5F18E2EA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2392,6 +2392,17 @@ name = iOS; sourceTree = ""; }; + 7182268F2587432E00661B83 /* CocoaAsyncSocket */ = { + isa = PBXGroup; + children = ( + 718226C72587443600661B83 /* GCDAsyncSocket.h */, + 718226C82587443600661B83 /* GCDAsyncSocket.m */, + 718226C62587443600661B83 /* GCDAsyncUdpSocket.h */, + 718226C92587443600661B83 /* GCDAsyncUdpSocket.m */, + ); + name = CocoaAsyncSocket; + sourceTree = ""; + }; 89DE54DD1FA69115F5538E11 /* IntegrationTests_tvOS */ = { isa = PBXGroup; children = ( @@ -2509,6 +2520,14 @@ path = IntegrationApp_watchOS; sourceTree = ""; }; + E444DC4A24912EC40060D7EB /* Vendor */ = { + isa = PBXGroup; + children = ( + 7182268F2587432E00661B83 /* CocoaAsyncSocket */, + ); + name = Vendor; + sourceTree = ""; + }; E94EE979A7D7C50FDC1EFE7D /* IntegrationTests_watchOS */ = { isa = PBXGroup; children = ( @@ -3176,6 +3195,21 @@ path = XCTUITestRunner; sourceTree = SOURCE_ROOT; }; + F47AAEACC427354CDC7C935F /* IntegrationApp_tvOS */ = { + isa = PBXGroup; + children = ( + 9DD3770A67ED561EBE5E443A /* main.m */, + 242FCA9DD0E30D748E0A1969 /* AppDelegate.h */, + 721E280BFFEFFD40C89277AF /* AppDelegate.m */, + 7F87CD156E93338642EEFD54 /* ViewController.h */, + 5D7DD32943CAC7838F942ED5 /* ViewController.m */, + E2F99C1A19D7B6D5B872D084 /* Info.plist */, + 0F719C2804F6473A9C4D3090 /* SceneDelegate.h */, + A87AE5544E9A5CA7C9168DDF /* SceneDelegate.m */, + ); + path = IntegrationApp_tvOS; + sourceTree = ""; + }; FBCAFE00000000000000500B /* WebDriverAgentBroadcast */ = { isa = PBXGroup; children = ( @@ -3209,40 +3243,6 @@ path = WebDriverAgentTunnel; sourceTree = ""; }; - F47AAEACC427354CDC7C935F /* IntegrationApp_tvOS */ = { - isa = PBXGroup; - children = ( - 9DD3770A67ED561EBE5E443A /* main.m */, - 242FCA9DD0E30D748E0A1969 /* AppDelegate.h */, - 721E280BFFEFFD40C89277AF /* AppDelegate.m */, - 7F87CD156E93338642EEFD54 /* ViewController.h */, - 5D7DD32943CAC7838F942ED5 /* ViewController.m */, - E2F99C1A19D7B6D5B872D084 /* Info.plist */, - 0F719C2804F6473A9C4D3090 /* SceneDelegate.h */, - A87AE5544E9A5CA7C9168DDF /* SceneDelegate.m */, - ); - path = IntegrationApp_tvOS; - sourceTree = ""; - }; - 7182268F2587432E00661B83 /* CocoaAsyncSocket */ = { - isa = PBXGroup; - children = ( - 718226C72587443600661B83 /* GCDAsyncSocket.h */, - 718226C82587443600661B83 /* GCDAsyncSocket.m */, - 718226C62587443600661B83 /* GCDAsyncUdpSocket.h */, - 718226C92587443600661B83 /* GCDAsyncUdpSocket.m */, - ); - name = CocoaAsyncSocket; - sourceTree = ""; - }; - E444DC4A24912EC40060D7EB /* Vendor */ = { - isa = PBXGroup; - children = ( - 7182268F2587432E00661B83 /* CocoaAsyncSocket */, - ); - name = Vendor; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -5577,7 +5577,7 @@ ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; TARGETED_DEVICE_FAMILY = 3; @@ -5637,7 +5637,7 @@ ); MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; TARGETED_DEVICE_FAMILY = 3; @@ -6593,8 +6593,8 @@ baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = "$(WDA_RUNNER_ENTITLEMENTS)"; CLANG_STATIC_ANALYZER_MODE = deep; + CODE_SIGN_ENTITLEMENTS = "$(WDA_RUNNER_ENTITLEMENTS)"; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6612,7 +6612,7 @@ "$(inherited)", "-all_load", ); - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; USES_XCTRUNNER = YES; WARNING_CFLAGS = ( @@ -6648,8 +6648,8 @@ baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = "$(WDA_RUNNER_ENTITLEMENTS)"; CLANG_STATIC_ANALYZER_MODE = deep; + CODE_SIGN_ENTITLEMENTS = "$(WDA_RUNNER_ENTITLEMENTS)"; ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = WebDriverAgentRunner/Info.plist; @@ -6667,7 +6667,7 @@ "$(inherited)", "-all_load", ); - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; USES_XCTRUNNER = YES; WARNING_CFLAGS = ( @@ -6706,7 +6706,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; GCC_TREAT_WARNINGS_AS_ERRORS = NO; INFOPLIST_FILE = WebDriverAgentBroadcast/Info.plist; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner.xctrunner.broadcast; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER).xctrunner.broadcast"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -6722,7 +6722,7 @@ GCC_TREAT_WARNINGS_AS_ERRORS = NO; INFOPLIST_FILE = WebDriverAgentBroadcast/Info.plist; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner.xctrunner.broadcast; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER).xctrunner.broadcast"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -6737,9 +6737,10 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = "$(WDA_TUNNEL_ENTITLEMENTS)"; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; GCC_TREAT_WARNINGS_AS_ERRORS = NO; INFOPLIST_FILE = WebDriverAgentTunnel/Info.plist; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner.xctrunner.tunnel; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER).xctrunner.tunnel"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -6753,10 +6754,11 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = "$(WDA_TUNNEL_ENTITLEMENTS)"; + DEVELOPMENT_TEAM = ""; GCC_TREAT_WARNINGS_AS_ERRORS = NO; INFOPLIST_FILE = WebDriverAgentTunnel/Info.plist; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner.xctrunner.tunnel; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER).xctrunner.tunnel"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -6971,29 +6973,29 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - FBCAFE000000000000005003 /* Build configuration list for PBXNativeTarget "WebDriverAgentBroadcast" */ = { + F80B35927AACA48FC55D05B5 /* Build configuration list for PBXNativeTarget "WebDriverAgentLib_watchOS" */ = { isa = XCConfigurationList; buildConfigurations = ( - FBCAFE000000000000005004 /* Debug */, - FBCAFE000000000000005005 /* Release */, + 9AD9FD0D35FBBD5C87BC6F5B /* Debug */, + 12BC722A112C4D54D64F1A9B /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - FBCAFE000000000000007103 /* Build configuration list for PBXNativeTarget "WebDriverAgentTunnel" */ = { + FBCAFE000000000000005003 /* Build configuration list for PBXNativeTarget "WebDriverAgentBroadcast" */ = { isa = XCConfigurationList; buildConfigurations = ( - FBCAFE000000000000007104 /* Debug */, - FBCAFE000000000000007105 /* Release */, + FBCAFE000000000000005004 /* Debug */, + FBCAFE000000000000005005 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F80B35927AACA48FC55D05B5 /* Build configuration list for PBXNativeTarget "WebDriverAgentLib_watchOS" */ = { + FBCAFE000000000000007103 /* Build configuration list for PBXNativeTarget "WebDriverAgentTunnel" */ = { isa = XCConfigurationList; buildConfigurations = ( - 9AD9FD0D35FBBD5C87BC6F5B /* Debug */, - 12BC722A112C4D54D64F1A9B /* Release */, + FBCAFE000000000000007104 /* Debug */, + FBCAFE000000000000007105 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme index 6dbf148f35..8f1d503318 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme @@ -46,6 +46,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "9D6B02D8FC050BAF7159284F" BuildableName = "IntegrationApp_watchOS.app" + BlueprintName = "IntegrationApp_watchOS" ReferencedContainer = "container:WebDriverAgent.xcodeproj"> @@ -62,6 +63,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "9D6B02D8FC050BAF7159284F" BuildableName = "IntegrationApp_watchOS.app" + BlueprintName = "IntegrationApp_watchOS" ReferencedContainer = "container:WebDriverAgent.xcodeproj"> diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m index af89defa02..be57b46b09 100644 --- a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m @@ -8,6 +8,8 @@ #import "FBMobilerunSocks5Commands.h" +#include + #import "FBCommandStatus.h" #import "FBResponsePayload.h" #import "FBRoute.h" @@ -16,6 +18,28 @@ #import "FBSocks5URI.h" static const NSTimeInterval FBSocks5ConnectDefaultTimeout = 30.0; +static const NSTimeInterval FBSocks5ConnectMaximumTimeout = 300.0; + +BOOL FBSocks5ConnectTimeoutFromValue(id _Nullable value, NSTimeInterval *timeout) +{ + NSTimeInterval validatedTimeout = FBSocks5ConnectDefaultTimeout; + if (nil != value) { + if (![value isKindOfClass:NSNumber.class] + || CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) { + return NO; + } + validatedTimeout = [value doubleValue]; + if (!isfinite(validatedTimeout) + || validatedTimeout <= 0 + || validatedTimeout > FBSocks5ConnectMaximumTimeout) { + return NO; + } + } + if (NULL != timeout) { + *timeout = validatedTimeout; + } + return YES; +} @implementation FBMobilerunSocks5Commands @@ -25,12 +49,12 @@ + (NSArray *)routes { return @[ - [[FBRoute POST:@"/mobilerun/socks5/connect"].onControlQueue respondWithTarget:self action:@selector(handleConnect:)], - [[FBRoute POST:@"/mobilerun/socks5/disconnect"].onControlQueue respondWithTarget:self action:@selector(handleDisconnect:)], - [[FBRoute GET:@"/mobilerun/socks5/stats"].onControlQueue respondWithTarget:self action:@selector(handleStats:)], - [[FBRoute POST:@"/mobilerun/socks5/connect"].withoutSession.onControlQueue respondWithTarget:self action:@selector(handleConnect:)], - [[FBRoute POST:@"/mobilerun/socks5/disconnect"].withoutSession.onControlQueue respondWithTarget:self action:@selector(handleDisconnect:)], - [[FBRoute GET:@"/mobilerun/socks5/stats"].withoutSession.onControlQueue respondWithTarget:self action:@selector(handleStats:)], + [[FBRoute POST:@"/mobilerun/socks5/connect"].standalone respondWithTarget:self action:@selector(handleConnect:)], + [[FBRoute POST:@"/mobilerun/socks5/disconnect"].standalone respondWithTarget:self action:@selector(handleDisconnect:)], + [[FBRoute GET:@"/mobilerun/socks5/stats"].standalone respondWithTarget:self action:@selector(handleStats:)], + [[FBRoute POST:@"/mobilerun/socks5/connect"].withoutSession.standalone respondWithTarget:self action:@selector(handleConnect:)], + [[FBRoute POST:@"/mobilerun/socks5/disconnect"].withoutSession.standalone respondWithTarget:self action:@selector(handleDisconnect:)], + [[FBRoute GET:@"/mobilerun/socks5/stats"].withoutSession.standalone respondWithTarget:self action:@selector(handleStats:)], ]; } @@ -49,10 +73,12 @@ + (NSArray *)routes return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:parseError.localizedDescription traceback:nil]); } - NSTimeInterval timeout = FBSocks5ConnectDefaultTimeout; + NSTimeInterval timeout; id timeoutValue = request.arguments[@"timeout"]; - if ([timeoutValue isKindOfClass:NSNumber.class] && [timeoutValue doubleValue] > 0) { - timeout = [timeoutValue doubleValue]; + if (!FBSocks5ConnectTimeoutFromValue(timeoutValue, &timeout)) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage: + @"'timeout' must be a finite number greater than 0 and at most 300 seconds" + traceback:nil]); } NSArray *consentLabels = nil; id labelsValue = request.arguments[@"consentButtonLabels"]; @@ -69,6 +95,7 @@ + (NSArray *)routes // a tunnel that is already gone. NSDictionary *stats = [FBSocks5TunnelManager.sharedInstance connectWithURI:uri + controlAddress:request.clientAddress timeout:timeout consentButtonLabels:consentLabels error:&error]; diff --git a/WebDriverAgentLib/Routing/FBHTTPServer.h b/WebDriverAgentLib/Routing/FBHTTPServer.h index 75ea80ccd4..9898a0c3c4 100644 --- a/WebDriverAgentLib/Routing/FBHTTPServer.h +++ b/WebDriverAgentLib/Routing/FBHTTPServer.h @@ -56,10 +56,10 @@ NS_ASSUME_NONNULL_BEGIN /** Registers a route handler that, when `standalone` is YES, bypasses -routeQueue entirely so a - handler stuck on that queue can never block it. Concurrent requests to the same method+path are - coalesced into a single in-flight execution, whose response is delivered to all of them; anything - else runs on its own queue, so distinct standalone endpoints always execute in parallel with each - other and with whatever is stuck on -routeQueue. + handler stuck on that queue can never block it. Concurrent requests from the same client address + with the same method, path, query, and body are coalesced into a single in-flight execution, whose + response is delivered to all of them; anything else runs on its own queue, so distinct standalone + requests always execute in parallel with each other and with whatever is stuck on -routeQueue. */ - (void)handleMethod:(NSString *)method withPath:(NSString *)path diff --git a/WebDriverAgentLib/Routing/FBHTTPServer.m b/WebDriverAgentLib/Routing/FBHTTPServer.m index 914e564c2a..fb7900c30e 100644 --- a/WebDriverAgentLib/Routing/FBHTTPServer.m +++ b/WebDriverAgentLib/Routing/FBHTTPServer.m @@ -35,6 +35,24 @@ // without limit. Far above anything a real request needs. static const NSUInteger FBMaxRequestHeaderSize = 64 * 1024; +static NSString * _Nullable FBClientAddress(nw_connection_t client) +{ + nw_endpoint_t endpoint = nw_connection_copy_endpoint(client); + if (nil == endpoint) { + return nil; + } + const char *hostname = nw_endpoint_get_hostname(endpoint); + return NULL == hostname ? nil : [NSString stringWithUTF8String:hostname]; +} + +NSArray *FBStandaloneRequestIdentity(NSString *method, + NSString *pathAndQuery, + NSData *body, + NSString *_Nullable clientAddress) +{ + return @[method, pathAndQuery, body, clientAddress ?: NSNull.null]; +} + // ASCII decimal digits only. -integerValue must not be used here: it maps garbage silently // ("bogus" -> 0, "12abc" -> 12), desyncing the framing of every later request on the connection. static BOOL FBParseContentLength(NSString *value, NSUInteger *outLength) @@ -123,9 +141,9 @@ @interface FBHTTPServer () // -processBufferForClient: from starting the next pipelined request, so responses on one // connection can't be written out of order. Guarded by @synchronized(self.connectionBuffers). @property (nonatomic, strong) NSMutableSet *connectionsAwaitingResponse; -// Keyed by "METHOD path" - requests waiting on an already in-flight standalone request for that -// endpoint. Guarded by @synchronized(self.standaloneWaiters). -@property (nonatomic, strong) NSMutableDictionary *> *standaloneWaiters; +// Keyed by an opaque method/path/body tuple - requests waiting on an already in-flight identical +// standalone request. Guarded by @synchronized(self.standaloneWaiters). +@property (nonatomic, strong) NSMutableDictionary *> *standaloneWaiters; // Keyed by the "sessionID" path param - requests currently queued or executing for that session, // standalone or not (except DELETE /session itself - see -dispatchMethod:). See // -abandonPendingRequestsForSessionID:. Guarded by @synchronized(self.pendingSessionRequests). @@ -612,7 +630,10 @@ - (void)dispatchMethod:(NSString *)method pathAndQuery:(NSString *)pathAndQuery } NSURL *url = [NSURL URLWithString:path] ?: [NSURL URLWithString:@"/"]; - RouteRequest *request = [[RouteRequest alloc] initWithURL:url params:params.copy body:body]; + RouteRequest *request = [[RouteRequest alloc] initWithURL:url + params:params.copy + body:body + clientAddress:FBClientAddress(client)]; RouteResponse *response = [RouteResponse new]; [self applyDefaultHeadersToResponse:response]; @@ -732,8 +753,11 @@ - (void)dispatchStandaloneRoute:(FBHTTPRoute *)route pathAndQuery:(NSString *)pathAndQuery sessionID:(nullable NSString *)sessionID { - // Includes the query string so requests with different params are never coalesced together. - NSString *key = [NSString stringWithFormat:@"%@ %@", method, pathAndQuery]; + // Includes the query string, raw body bytes, and controller address so only semantically + // identical requests from the same controller are coalesced. Keeping these values opaque also + // avoids exposing request contents in queue labels. + NSArray *key = FBStandaloneRequestIdentity(method, pathAndQuery, request.body, + request.clientAddress); FBPendingRequest *waiter = [[FBPendingRequest alloc] initWithClient:client]; if (nil != sessionID) { RouteResponse *abandonedResponse = [self trackPendingRequest:waiter forSessionID:sessionID]; @@ -758,7 +782,7 @@ - (void)dispatchStandaloneRoute:(FBHTTPRoute *)route return; } - dispatch_queue_t queue = dispatch_queue_create(key.UTF8String, DISPATCH_QUEUE_SERIAL); + dispatch_queue_t queue = dispatch_queue_create("com.facebook.WebDriverAgent.standalone-route", DISPATCH_QUEUE_SERIAL); __weak typeof(self) weakSelf = self; dispatch_async(queue, ^{ route.block(request, response); diff --git a/WebDriverAgentLib/Routing/FBRouteRequest-Private.h b/WebDriverAgentLib/Routing/FBRouteRequest-Private.h index 7144bd80ba..5383981761 100644 --- a/WebDriverAgentLib/Routing/FBRouteRequest-Private.h +++ b/WebDriverAgentLib/Routing/FBRouteRequest-Private.h @@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, readwrite) NSURL *URL; @property (nonatomic, copy, readwrite) NSDictionary *parameters; @property (nonatomic, copy, readwrite) NSDictionary *arguments; +@property (nonatomic, copy, readwrite, nullable) NSString *clientAddress; @property (nonatomic, strong, readwrite) FBSession *session; @end diff --git a/WebDriverAgentLib/Routing/FBRouteRequest.h b/WebDriverAgentLib/Routing/FBRouteRequest.h index c7938d5d67..3fe9f9a6ae 100644 --- a/WebDriverAgentLib/Routing/FBRouteRequest.h +++ b/WebDriverAgentLib/Routing/FBRouteRequest.h @@ -26,6 +26,9 @@ NS_ASSUME_NONNULL_BEGIN /*! Arguments sent with that request */ @property (nonatomic, copy, readonly) NSDictionary *arguments; +/*! Remote IP address of the HTTP client, when available */ +@property (nonatomic, copy, readonly, nullable) NSString *clientAddress; + /*! Session associated with that request */ @property (nonatomic, strong, readonly) FBSession *session; @@ -34,6 +37,11 @@ NS_ASSUME_NONNULL_BEGIN */ + (instancetype)routeRequestWithURL:(NSURL *)URL parameters:(NSDictionary *)parameters arguments:(NSDictionary *)arguments; ++ (instancetype)routeRequestWithURL:(NSURL *)URL + parameters:(NSDictionary *)parameters + arguments:(NSDictionary *)arguments + clientAddress:(nullable NSString *)clientAddress; + @end NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Routing/FBRouteRequest.m b/WebDriverAgentLib/Routing/FBRouteRequest.m index b8656d2852..3fd1333a15 100644 --- a/WebDriverAgentLib/Routing/FBRouteRequest.m +++ b/WebDriverAgentLib/Routing/FBRouteRequest.m @@ -8,14 +8,102 @@ #import "FBRouteRequest-Private.h" +static NSString *FBRedactedURIString(NSString *value) +{ + NSRange schemeSeparator = [value rangeOfString:@"://"]; + if (NSNotFound == schemeSeparator.location) { + return value; + } + NSUInteger authorityStart = NSMaxRange(schemeSeparator); + NSRange authorityTail = NSMakeRange(authorityStart, value.length - authorityStart); + NSRange authorityEnd = [value rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@"/?#"] + options:(NSStringCompareOptions)0 + range:authorityTail]; + NSUInteger authorityLength = NSNotFound == authorityEnd.location + ? value.length - authorityStart + : authorityEnd.location - authorityStart; + NSRange userInfoSeparator = [value rangeOfString:@"@" + options:NSBackwardsSearch + range:NSMakeRange(authorityStart, authorityLength)]; + if (NSNotFound == userInfoSeparator.location) { + return value; + } + + NSMutableString *redacted = value.mutableCopy; + [redacted replaceCharactersInRange:NSMakeRange(authorityStart, + userInfoSeparator.location - authorityStart) + withString:@""]; + return redacted.copy; +} + +static NSString *FBRedactedProxyURIString(NSString *value) +{ + NSString *redacted = FBRedactedURIString(value); + if (![redacted isEqualToString:value]) { + return redacted; + } + NSRange userInfoSeparator = [value rangeOfString:@"@" options:NSBackwardsSearch]; + if (NSNotFound == userInfoSeparator.location) { + return value; + } + NSMutableString *fallback = value.mutableCopy; + [fallback replaceCharactersInRange:NSMakeRange(0, userInfoSeparator.location) + withString:@""]; + return fallback.copy; +} + +static id FBRedactedRequestLogValue(id value) +{ + if ([value isKindOfClass:NSString.class]) { + return FBRedactedURIString((NSString *)value); + } + if ([value isKindOfClass:NSArray.class]) { + NSMutableArray *result = [NSMutableArray arrayWithCapacity:[(NSArray *)value count]]; + for (id item in (NSArray *)value) { + [result addObject:FBRedactedRequestLogValue(item)]; + } + return result.copy; + } + if ([value isKindOfClass:NSDictionary.class]) { + NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:[(NSDictionary *)value count]]; + [(NSDictionary *)value enumerateKeysAndObjectsUsingBlock:^(id key, id item, BOOL *stop) { + result[key] = FBRedactedRequestLogValue(item); + }]; + return result.copy; + } + return value; +} + +static NSDictionary *FBRedactedRequestArguments(NSURL *URL, NSDictionary *arguments) +{ + NSMutableDictionary *redacted = [(NSDictionary *)FBRedactedRequestLogValue(arguments) mutableCopy]; + id uri = arguments[@"uri"]; + if ([URL.path hasSuffix:@"/mobilerun/socks5/connect"] && [uri isKindOfClass:NSString.class]) { + redacted[@"uri"] = FBRedactedProxyURIString((NSString *)uri); + } + return redacted.copy; +} + @implementation FBRouteRequest + (instancetype)routeRequestWithURL:(NSURL *)URL parameters:(NSDictionary *)parameters arguments:(NSDictionary *)arguments +{ + return [self routeRequestWithURL:URL + parameters:parameters + arguments:arguments + clientAddress:nil]; +} + ++ (instancetype)routeRequestWithURL:(NSURL *)URL + parameters:(NSDictionary *)parameters + arguments:(NSDictionary *)arguments + clientAddress:(nullable NSString *)clientAddress { FBRouteRequest *request = [self.class new]; request.URL = URL; request.parameters = parameters; request.arguments = arguments; + request.clientAddress = clientAddress; return request; } @@ -25,7 +113,7 @@ - (NSString *)description @"Request URL %@ | Params %@ | Arguments %@", self.URL, self.parameters, - self.arguments + FBRedactedRequestArguments(self.URL, self.arguments) ]; } diff --git a/WebDriverAgentLib/Routing/FBWebServer.h b/WebDriverAgentLib/Routing/FBWebServer.h index d0c7af365c..fd77fc6f3f 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.h +++ b/WebDriverAgentLib/Routing/FBWebServer.h @@ -41,7 +41,7 @@ NS_ASSUME_NONNULL_BEGIN Runs a block that touches XCUI/XCTest state on the main thread, serialized through the same automation funnel the main-queue-served routes use. - Routes marked `onControlQueue` are served off the main queue, so they must not call + Routes marked `standalone` are served off the main queue, so they must not call XCUI directly. Hopping straight to the main queue is not enough either: such a block could be drained inside another handler's run-loop spin, which is exactly the reentrancy the funnel exists to prevent. Going through the funnel first makes the @@ -54,6 +54,20 @@ NS_ASSUME_NONNULL_BEGIN */ + (void)performAutomationBlockOnMainQueue:(NS_NOESCAPE dispatch_block_t)block; +/** + Runs an XCUI/XCTest block through the automation funnel only if it can begin by `deadline`. + + If the block is still queued when the deadline expires, it is cancelled and will not execute + later. Once execution has begun, this method preserves the synchronous contract and waits for + the block to finish. + + @param block The XCUI-touching work. + @param deadline The latest date at which the queued block may begin executing. + @return YES if the block began execution, otherwise NO. + */ ++ (BOOL)performAutomationBlockOnMainQueue:(dispatch_block_t)block + beforeDate:(NSDate *)deadline; + @end /** diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index f09099ef50..f7a82422d8 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -9,6 +9,7 @@ #import "FBWebServer.h" #import "FBHTTPServer.h" +#import #import "FBMjpegServer.h" #import "FBTCPSocket.h" #if !TARGET_OS_WATCH @@ -144,13 +145,128 @@ + (void)performAutomationBlockOnMainQueue:(NS_NOESCAPE dispatch_block_t)block block(); return; } + __block NSException *blockException = nil; if (NULL != dispatch_get_specific(FBAutomationFunnelKey)) { - dispatch_sync(dispatch_get_main_queue(), block); + dispatch_sync(dispatch_get_main_queue(), ^{ + @try { + block(); + } @catch (NSException *exception) { + blockException = exception; + } + }); + if (nil != blockException) { + @throw blockException; + } return; } dispatch_sync(self.automationFunnelQueue, ^{ - dispatch_sync(dispatch_get_main_queue(), block); + dispatch_sync(dispatch_get_main_queue(), ^{ + @try { + block(); + } @catch (NSException *exception) { + blockException = exception; + } + }); + }); + if (nil != blockException) { + @throw blockException; + } +} + ++ (BOOL)performAutomationBlockOnMainQueue:(dispatch_block_t)block + beforeDate:(NSDate *)deadline +{ + if (NSThread.isMainThread) { + if (deadline.timeIntervalSinceNow <= 0) { + return NO; + } + block(); + return YES; + } + if (NULL != dispatch_get_specific(FBAutomationFunnelKey)) { + __block BOOL executed = NO; + __block NSException *blockException = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + if (deadline.timeIntervalSinceNow <= 0) { + return; + } + executed = YES; + @try { + block(); + } @catch (NSException *exception) { + blockException = exception; + } + }); + if (nil != blockException) { + @throw blockException; + } + return executed; + } + + enum { + FBAutomationBlockPending, + FBAutomationBlockExecuting, + FBAutomationBlockCancelled, + FBAutomationBlockFinished, + }; + __block volatile atomic_int state = FBAutomationBlockPending; + __block NSException *blockException = nil; + dispatch_semaphore_t completion = dispatch_semaphore_create(0); + dispatch_async(self.automationFunnelQueue, ^{ + @try { + if (atomic_load_explicit(&state, memory_order_acquire) == FBAutomationBlockCancelled) { + return; + } + dispatch_sync(dispatch_get_main_queue(), ^{ + int expected = FBAutomationBlockPending; + if (deadline.timeIntervalSinceNow > 0 + && atomic_compare_exchange_strong_explicit(&state, &expected, + FBAutomationBlockExecuting, + memory_order_acq_rel, + memory_order_acquire)) { + @try { + block(); + } @catch (NSException *exception) { + blockException = exception; + } @finally { + atomic_store_explicit(&state, FBAutomationBlockFinished, memory_order_release); + } + } else { + expected = FBAutomationBlockPending; + atomic_compare_exchange_strong_explicit(&state, &expected, + FBAutomationBlockCancelled, + memory_order_acq_rel, + memory_order_acquire); + } + }); + } @finally { + dispatch_semaphore_signal(completion); + } }); + + NSTimeInterval budget = deadline.timeIntervalSinceNow; + long waitResult = budget <= 0 + ? 1 + : dispatch_semaphore_wait(completion, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(budget * NSEC_PER_SEC))); + if (0 != waitResult) { + int expected = FBAutomationBlockPending; + if (atomic_compare_exchange_strong_explicit(&state, &expected, + FBAutomationBlockCancelled, + memory_order_acq_rel, + memory_order_acquire)) { + return NO; + } + if (atomic_load_explicit(&state, memory_order_acquire) == FBAutomationBlockCancelled) { + return NO; + } + dispatch_semaphore_wait(completion, DISPATCH_TIME_FOREVER); + } + BOOL finished = atomic_load_explicit(&state, memory_order_acquire) == FBAutomationBlockFinished; + if (finished && nil != blockException) { + @throw blockException; + } + return finished; } - (BOOL)startHTTPServer @@ -329,6 +445,7 @@ - (void)registerRouteHandlers:(NSArray *)commandHandlerClasses routeRequestWithURL:request.url parameters:request.params arguments:arguments ?: @{} + clientAddress:request.clientAddress ]; [FBLogger verboseLog:routeParams.description]; diff --git a/WebDriverAgentLib/Routing/RouteRequest.h b/WebDriverAgentLib/Routing/RouteRequest.h index 1a5c1b458c..f1cbeb710b 100644 --- a/WebDriverAgentLib/Routing/RouteRequest.h +++ b/WebDriverAgentLib/Routing/RouteRequest.h @@ -18,10 +18,12 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, readonly) NSURL *url; @property (nonatomic, copy, readonly) NSDictionary *params; @property (nonatomic, copy, readonly) NSData *body; +@property (nonatomic, copy, readonly, nullable) NSString *clientAddress; - (instancetype)initWithURL:(NSURL *)url params:(NSDictionary *)params - body:(NSData *)body; + body:(NSData *)body + clientAddress:(nullable NSString *)clientAddress; @end diff --git a/WebDriverAgentLib/Routing/RouteRequest.m b/WebDriverAgentLib/Routing/RouteRequest.m index 910a24e555..4793872400 100644 --- a/WebDriverAgentLib/Routing/RouteRequest.m +++ b/WebDriverAgentLib/Routing/RouteRequest.m @@ -13,11 +13,13 @@ @implementation RouteRequest - (instancetype)initWithURL:(NSURL *)url params:(NSDictionary *)params body:(NSData *)body + clientAddress:(nullable NSString *)clientAddress { if ((self = [super init])) { _url = url.copy; _params = params.copy; _body = body.copy; + _clientAddress = clientAddress.copy; } return self; } diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h index be79ed7c9f..f40e6ca9d5 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h @@ -67,6 +67,12 @@ typedef NS_ERROR_ENUM(FBSocks5TunnelManagerErrorDomain, FBSocks5TunnelManagerErr consentButtonLabels:(nullable NSArray *)consentButtonLabels error:(NSError **)error; +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + controlAddress:(nullable NSString *)controlAddress + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error; + /** Stops the running tunnel (the VPN configuration stays installed). Succeeds when no tunnel is running. diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 915ee8cd64..04ee72062b 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -8,6 +8,8 @@ #import "FBSocks5TunnelManager.h" +#import + #import "FBSocks5TunnelProtocol.h" #import "FBSocks5URI.h" @@ -35,6 +37,102 @@ static BOOL FBSocks5TunnelExtensionEmbedded(NSBundle *bundle) && isDirectory; } +static const NSTimeInterval FBSocks5PreferencesTimeout = 10.0; + +FBSocks5TunnelManagerError FBSocks5TunnelManagerSaveFailureCode(BOOL completed) +{ + return completed ? FBSocks5TunnelManagerErrorNotAuthorized : FBSocks5TunnelManagerErrorTimeout; +} + +/** + How long an individual wait may block: whatever is left of the caller's whole-flow deadline, + never more than that stage's own cap. `deadline` is nil for the flows that do not carry one + (disconnect, stats), which then just get the cap. A non-positive result means the caller's + budget is exhausted and the stage must not start at all. + */ +static NSTimeInterval FBSocks5RemainingTimeout(NSDate *_Nullable deadline, NSTimeInterval cap) +{ + return nil == deadline ? cap : MIN(cap, deadline.timeIntervalSinceNow); +} + +@interface FBSocks5LifecycleGuard : NSObject +@property (nonatomic, strong) NSRecursiveLock *lifecycleLock; +/** + Signalled when a saveToPreferences that outlived its request finally completes. + + Returning from a timed-out save does not cancel it - NetworkExtension can still persist the + manager afterwards. A follow-up operation that ran before that landed would load no manager, + build a second one with the same provider id, and end up with two persisted configurations. + */ +@property (atomic, strong, nullable) dispatch_semaphore_t pendingSaveSignal; +- (BOOL)performLockedWithDeadline:(nullable NSDate *)deadline + block:(NS_NOESCAPE dispatch_block_t)block; +- (void)performLocked:(NS_NOESCAPE dispatch_block_t)block; +- (BOOL)fencePendingSaveWithDeadline:(nullable NSDate *)deadline error:(NSError **)error; +@end + +@implementation FBSocks5LifecycleGuard + +- (instancetype)init +{ + self = [super init]; + if (nil != self) { + _lifecycleLock = [[NSRecursiveLock alloc] init]; + _lifecycleLock.name = @"com.facebook.WebDriverAgent.socks5-lifecycle"; + } + return self; +} + +- (BOOL)performLockedWithDeadline:(nullable NSDate *)deadline + block:(NS_NOESCAPE dispatch_block_t)block +{ + BOOL acquired; + if (nil == deadline) { + [self.lifecycleLock lock]; + acquired = YES; + } else { + acquired = [self.lifecycleLock lockBeforeDate:(NSDate *)deadline]; + } + if (!acquired) { + return NO; + } + @try { + block(); + } @finally { + [self.lifecycleLock unlock]; + } + return YES; +} + +- (void)performLocked:(NS_NOESCAPE dispatch_block_t)block +{ + [self performLockedWithDeadline:nil block:block]; +} + +- (BOOL)fencePendingSaveWithDeadline:(nullable NSDate *)deadline error:(NSError **)error +{ + dispatch_semaphore_t signal = self.pendingSaveSignal; + if (nil == signal) { + return YES; + } + NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout); + if (budget <= 0 + || 0 != dispatch_semaphore_wait(signal, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(budget * NSEC_PER_SEC)))) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out waiting for a previous VPN configuration save to finish"); + } + self.pendingSaveSignal = nil; + return YES; +} + +@end + +@interface FBSocks5TunnelManager () +/** Serializes the whole tunnel lifecycle without allowing a timed acquisition to execute later. */ +@property (nonatomic, strong) FBSocks5LifecycleGuard *lifecycle; +@end + static NSMutableDictionary *FBSocks5DisconnectedStats(void) { return [@{ @@ -46,6 +144,11 @@ static BOOL FBSocks5TunnelExtensionEmbedded(NSBundle *bundle) } mutableCopy]; } +NSDictionary *_Nullable FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(NSBundle *bundle) +{ + return FBSocks5TunnelExtensionEmbedded(bundle) ? nil : FBSocks5DisconnectedStats().copy; +} + #if TARGET_OS_SIMULATOR || TARGET_OS_TV @implementation FBSocks5TunnelManager @@ -64,6 +167,19 @@ + (instancetype)sharedInstance timeout:(NSTimeInterval)timeout consentButtonLabels:(nullable NSArray *)consentButtonLabels error:(NSError **)error +{ + return [self connectWithURI:uri + controlAddress:nil + timeout:timeout + consentButtonLabels:consentButtonLabels + error:error]; +} + +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + controlAddress:(nullable NSString *)controlAddress + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error { FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); @@ -87,6 +203,15 @@ + (BOOL)isTunnelExtensionEmbeddedInBundle:(NSBundle *)bundle return FBSocks5TunnelExtensionEmbedded(bundle); } +- (instancetype)init +{ + self = [super init]; + if (nil != self) { + _lifecycle = [[FBSocks5LifecycleGuard alloc] init]; + } + return self; +} + @end #else @@ -103,11 +228,10 @@ + (BOOL)isTunnelExtensionEmbeddedInBundle:(NSBundle *)bundle #import "XCUIApplication+FBTouchAction.h" #import "XCUIElement.h" -// The embed script rewrites the extension bundle id to '.tunnel' -// (see Scripts/embed-tunnel-extension.sh), so derive it the same way at runtime. +// The extension target is built as '.xctrunner.tunnel', which becomes +// '.tunnel'; derive the provider id from that final host id. static NSString *const FBSocks5TunnelBundleSuffix = @".tunnel"; static NSString *const FBSocks5TunnelDescription = @"mobilerun SOCKS5"; -static const NSTimeInterval FBSocks5PreferencesTimeout = 10.0; static const NSTimeInterval FBSocks5StopTimeout = 10.0; static const NSTimeInterval FBSocks5StatsReplyTimeout = 3.0; static const NSTimeInterval FBSocks5DefaultConnectTimeout = 30.0; @@ -119,42 +243,10 @@ static uint64_t FBSocks5NowMs(void) return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) / NSEC_PER_MSEC; } -/// Queue-specific marker used to detect that the caller already holds the lifecycle queue. -static const void *FBSocks5LifecycleQueueKey = &FBSocks5LifecycleQueueKey; - -/** - How long an individual wait may block: whatever is left of the caller's whole-flow deadline, - never more than that stage's own cap. `deadline` is nil for the flows that do not carry one - (disconnect, stats), which then just get the cap. A non-positive result means the caller's - budget is exhausted and the stage must not start at all. - */ -static NSTimeInterval FBSocks5RemainingTimeout(NSDate *_Nullable deadline, NSTimeInterval cap) -{ - return nil == deadline ? cap : MIN(cap, deadline.timeIntervalSinceNow); -} - @interface FBSocks5TunnelManager () @property (nonatomic, nullable) NETunnelProviderManager *activeManager; /** Monotonic ms timestamp of the last consent tap dispatch; guards the re-attempt cooldown. */ @property (nonatomic) uint64_t lastConsentTapMs; -/** - Signalled when a saveToPreferences that outlived its request finally completes. - - Returning from a timed-out save does not cancel it - NetworkExtension can still persist the - manager afterwards. A follow-up operation that ran before that landed would load no manager, - build a second one with the same provider id, and end up with two persisted configurations; - ownManagerIn: could then hand a disconnected duplicate to disconnect, which would report - success while the real tunnel kept running. Non-nil means such a save is still outstanding. - */ -@property (atomic, nullable) dispatch_semaphore_t pendingSaveSignal; -/** - Serializes the whole tunnel lifecycle. The socks5 routes are marked onControlQueue, and - FBWebServer only funnels the non-control ones, so requests arriving over different HTTP - connections reach this singleton concurrently on their own connection queues. Without this - queue a disconnect could return while an in-flight connect goes on to start the tunnel, and - two connects could race different proxy configurations through stop/save/reload/start. - */ -@property (nonatomic, strong) dispatch_queue_t lifecycleQueue; @end @implementation FBSocks5TunnelManager @@ -178,44 +270,11 @@ - (instancetype)init { self = [super init]; if (nil != self) { - _lifecycleQueue = dispatch_queue_create("com.facebook.WebDriverAgent.socks5-lifecycle", - DISPATCH_QUEUE_SERIAL); - dispatch_queue_set_specific(_lifecycleQueue, FBSocks5LifecycleQueueKey, - (void *)FBSocks5LifecycleQueueKey, NULL); + _lifecycle = [[FBSocks5LifecycleGuard alloc] init]; } return self; } -/** - Blocks until a save left in flight by a timed-out connect settles, so the next operation sees - whatever it persisted instead of racing it. Safe to call from the lifecycle queue: the - completion is delivered on the main queue, which this queue does not occupy. - */ -- (void)fencePendingSaveWithDeadline:(nullable NSDate *)deadline -{ - dispatch_semaphore_t signal = self.pendingSaveSignal; - if (nil == signal) { - return; - } - NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout); - if (budget > 0) { - [FBLogger log:@"socks5: waiting for a previously timed-out VPN save to settle"]; - dispatch_semaphore_wait(signal, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(budget * NSEC_PER_SEC))); - } - // Cleared either way: a save that outlives even this is not worth blocking every later call on. - self.pendingSaveSignal = nil; -} - -/// Runs `block` with the lifecycle queue held, tolerating a caller that already holds it. -- (void)performLocked:(NS_NOESCAPE dispatch_block_t)block -{ - if (NULL != dispatch_get_specific(FBSocks5LifecycleQueueKey)) { - block(); - return; - } - dispatch_sync(self.lifecycleQueue, block); -} - #pragma mark - Helpers - (NSString *)providerBundleIdentifier @@ -234,18 +293,18 @@ - (BOOL)loadAllManagers:(NSArray **)outManagers } __block NSArray *managers = nil; __block NSError *loadError = nil; - __block BOOL done = NO; + __block volatile atomic_bool done = false; [NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray *all, NSError *err) { managers = all; loadError = err; - done = YES; + atomic_store_explicit(&done, true, memory_order_release); }]; [[[[FBRunLoopSpinner new] timeout:budget] interval:0.05] spinUntilTrue:^BOOL{ - return done; + return atomic_load_explicit(&done, memory_order_acquire); }]; // Running out of budget is a timeout, not an internal fault: collapsing the two would surface // a plain deadline miss as 'unknown error' instead of the documented timeout response. - if (!done) { + if (!atomic_load_explicit(&done, memory_order_acquire)) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, @"Timed out loading the VPN preferences"); } @@ -261,7 +320,7 @@ - (BOOL)loadAllManagers:(NSArray **)outManagers } // Returns the manager for our own provider, preferring one that is not idle. Duplicates with the -// same provider id can exist transiently (see pendingSaveSignal); picking whichever came first +// same provider id can exist transiently (see the pending-save fence); picking whichever came first // would let a disconnected duplicate shadow the running tunnel, so disconnect would report // success without stopping anything. - (nullable NETunnelProviderManager *)ownManagerIn:(NSArray *)managers @@ -302,7 +361,7 @@ - (BOOL)waitUntilStopped:(NETunnelProviderManager *)manager deadline:(nullable N // polled from within the save wait loop. Devices with a passcode additionally ask for it, // which cannot be automated (documented in docs/socks5-tunnel.md). // XCUI is only safe to touch from the main thread, and the socks5 routes are served off it -// (they are marked onControlQueue so the main queue stays free to drain the NetworkExtension +// (they are marked standalone so the main queue stays free to drain the NetworkExtension // completion handlers this class waits on). Every XCUI access below therefore hops onto the // automation funnel and then the main queue, which is the same path the main-queue-served // routes - e.g. the broadcast start/stop pair, whose consent handling this mirrors - take. @@ -357,10 +416,10 @@ - (nullable XCUIElement *)consentAlertButtonWithLabel:(NSString *)label return nil; } -- (BOOL)tapConsentButtonWithLabels:(NSArray *)labels +- (BOOL)tapConsentButtonWithLabels:(NSArray *)labels deadline:(NSDate *)deadline { __block BOOL dispatched = NO; - [FBWebServer performAutomationBlockOnMainQueue:^{ + BOOL acquired = [FBWebServer performAutomationBlockOnMainQueue:^{ XCUIApplication *system = XCUIApplication.fb_systemApplication; for (NSString *label in labels) { XCUIElement *button = [self consentAlertButtonWithLabel:label]; @@ -398,8 +457,8 @@ - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels dispatched = YES; return; } - }]; - return dispatched; + } beforeDate:deadline]; + return acquired && dispatched; } #pragma mark - Public API @@ -408,6 +467,19 @@ - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels timeout:(NSTimeInterval)timeout consentButtonLabels:(nullable NSArray *)consentButtonLabels error:(NSError **)error +{ + return [self connectWithURI:uri + controlAddress:nil + timeout:timeout + consentButtonLabels:consentButtonLabels + error:error]; +} + +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + controlAddress:(nullable NSString *)controlAddress + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error { // Start the clock before queueing, not inside the locked body: waiting behind another // operation is part of the caller's wall-clock budget, and a request with timeout:1 that @@ -423,8 +495,9 @@ - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels } __block NSDictionary *snapshot = nil; __block NSError *localError = nil; - [self performLocked:^{ + BOOL acquired = [self.lifecycle performLockedWithDeadline:deadline block:^{ if ([self lockedConnectWithURI:uri + controlAddress:controlAddress deadline:deadline consentButtonLabels:consentButtonLabels error:&localError]) { @@ -435,6 +508,10 @@ - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels snapshot = [self lockedStatsDictionaryWithDeadline:deadline]; } }]; + if (!acquired) { + FBSocks5Fail(&localError, FBSocks5TunnelManagerErrorTimeout, + @"Timed out waiting for another SOCKS5 lifecycle operation to finish"); + } if (nil == snapshot && nil != error) { *error = localError; } @@ -442,6 +519,7 @@ - (BOOL)tapConsentButtonWithLabels:(NSArray *)labels } - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri + controlAddress:(nullable NSString *)controlAddress deadline:(NSDate *)deadline consentButtonLabels:(nullable NSArray *)consentButtonLabels error:(NSError **)error @@ -449,7 +527,9 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri NSTimeInterval budget = deadline.timeIntervalSinceNow; NSArray *labels = consentButtonLabels.count > 0 ? consentButtonLabels : @[@"Allow"]; - [self fencePendingSaveWithDeadline:deadline]; + if (![self.lifecycle fencePendingSaveWithDeadline:deadline error:error]) { + return NO; + } NSArray *managers; if (![self loadAllManagers:&managers deadline:deadline error:error]) { return NO; @@ -478,19 +558,19 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri NETunnelProviderProtocol *protocol = [[NETunnelProviderProtocol alloc] init]; protocol.providerBundleIdentifier = self.providerBundleIdentifier; protocol.serverAddress = uri.host; - protocol.providerConfiguration = uri.providerConfiguration; + protocol.providerConfiguration = [uri providerConfigurationWithControlAddress:controlAddress]; protocol.disconnectOnSleep = NO; manager.protocolConfiguration = protocol; manager.localizedDescription = FBSocks5TunnelDescription; manager.enabled = YES; - __block BOOL saveDone = NO; + __block volatile atomic_bool saveDone = false; __block NSError *saveError = nil; dispatch_semaphore_t saveSignal = dispatch_semaphore_create(0); - self.pendingSaveSignal = saveSignal; + self.lifecycle.pendingSaveSignal = saveSignal; [manager saveToPreferencesWithCompletionHandler:^(NSError *err) { saveError = err; - saveDone = YES; + atomic_store_explicit(&saveDone, true, memory_order_release); dispatch_semaphore_signal(saveSignal); }]; // Keep re-attempting for as long as the alert is still up: a dispatched tap can be shed by the @@ -500,41 +580,41 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri // No MAX(..., 1.0) floor here: granting an already-exhausted request another second is // exactly the overshoot the caller's timeout is supposed to prevent. [[[[FBRunLoopSpinner new] timeout:deadline.timeIntervalSinceNow] interval:0.3] spinUntilTrue:^BOOL{ - if (saveDone) { + if (atomic_load_explicit(&saveDone, memory_order_acquire)) { return YES; } - if ([self tapConsentButtonWithLabels:labels]) { + if ([self tapConsentButtonWithLabels:labels deadline:deadline]) { consentTapped = YES; } return NO; }]; - if (!saveDone) { - return FBSocks5Fail(error, FBSocks5TunnelManagerErrorNotAuthorized, + if (!atomic_load_explicit(&saveDone, memory_order_acquire)) { + return FBSocks5Fail(error, FBSocks5TunnelManagerSaveFailureCode(NO), [NSString stringWithFormat: @"Timed out saving the VPN configuration. The consent alert was %@; " "pass 'consentButtonLabels' if the device language is not English, and note that " "devices with a passcode cannot confirm the VPN consent automatically", consentTapped ? @"confirmed" : @"not confirmed"]); } - self.pendingSaveSignal = nil; + self.lifecycle.pendingSaveSignal = nil; if (nil != saveError) { - return FBSocks5Fail(error, FBSocks5TunnelManagerErrorNotAuthorized, + return FBSocks5Fail(error, FBSocks5TunnelManagerSaveFailureCode(YES), [NSString stringWithFormat:@"The VPN configuration was not authorized: %@", saveError.localizedDescription]); } // A freshly saved configuration must be re-loaded before the tunnel can be started. - __block BOOL reloadDone = NO; + __block volatile atomic_bool reloadDone = false; __block NSError *reloadError = nil; [manager loadFromPreferencesWithCompletionHandler:^(NSError *err) { reloadError = err; - reloadDone = YES; + atomic_store_explicit(&reloadDone, true, memory_order_release); }]; [[[[FBRunLoopSpinner new] timeout:FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout)] interval:0.05] spinUntilTrue:^BOOL{ - return reloadDone; + return atomic_load_explicit(&reloadDone, memory_order_acquire); }]; // Same distinction as loadAllManagers:deadline:error: above. - if (!reloadDone) { + if (!atomic_load_explicit(&reloadDone, memory_order_acquire)) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, @"Timed out reloading the saved VPN configuration"); } @@ -544,8 +624,16 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri reloadError.localizedDescription]); } + if (deadline.timeIntervalSinceNow <= 0) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out before the SOCKS5 tunnel could start"); + } + NSDictionary *startOptions = @{ + FBSocks5OptionStartupDeadline: @(deadline.timeIntervalSinceReferenceDate), + }; NSError *startError; - if (![(NETunnelProviderSession *)manager.connection startVPNTunnelWithOptions:nil andReturnError:&startError]) { + if (![(NETunnelProviderSession *)manager.connection startVPNTunnelWithOptions:startOptions + andReturnError:&startError]) { return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, [NSString stringWithFormat:@"Cannot start the SOCKS5 tunnel: %@", startError.localizedDescription]); @@ -556,14 +644,14 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri __block BOOL startRejected = NO; __block BOOL leftIdle = NO; BOOL connected = [[[[FBRunLoopSpinner new] timeout:deadline.timeIntervalSinceNow] interval:0.2] spinUntilTrue:^BOOL{ - NEVPNStatus status = manager.connection.status; - if (status == NEVPNStatusConnected) { + NEVPNStatus currentStatus = manager.connection.status; + if (currentStatus == NEVPNStatusConnected) { return YES; } // The session can still read as disconnected for an instant after startVPNTunnel, so only // treat that as terminal once it has actually entered a starting state. Reasserting still // counts as in-flight; only a settled stop is terminal. - if (status != NEVPNStatusDisconnected && status != NEVPNStatusInvalid) { + if (currentStatus != NEVPNStatusDisconnected && currentStatus != NEVPNStatusInvalid) { leftIdle = YES; } else if (leftIdle) { startRejected = YES; @@ -592,9 +680,14 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri - (nullable NSDictionary *)disconnectWithError:(NSError **)error { + NSDictionary *unavailableSnapshot = + FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(NSBundle.mainBundle); + if (nil != unavailableSnapshot) { + return unavailableSnapshot; + } __block NSDictionary *snapshot = nil; __block NSError *localError = nil; - [self performLocked:^{ + [self.lifecycle performLocked:^{ if ([self lockedDisconnectWithError:&localError]) { snapshot = [self lockedStatsDictionary]; } @@ -609,7 +702,9 @@ - (BOOL)lockedDisconnectWithError:(NSError **)error { // Without this a disconnect racing a timed-out connect could load nothing, or load a duplicate // that is not the tunnel actually running, and report success while the VPN stayed up. - [self fencePendingSaveWithDeadline:nil]; + if (![self.lifecycle fencePendingSaveWithDeadline:nil error:error]) { + return NO; + } NSArray *managers; if (![self loadAllManagers:&managers deadline:nil error:error]) { return NO; @@ -637,7 +732,7 @@ - (BOOL)lockedDisconnectWithError:(NSError **)error - (NSDictionary *)statsDictionary { __block NSDictionary *snapshot = nil; - [self performLocked:^{ + [self.lifecycle performLocked:^{ snapshot = [self lockedStatsDictionary]; }]; return snapshot; @@ -680,7 +775,7 @@ - (BOOL)lockedDisconnectWithError:(NSError **)error } NETunnelProviderSession *session = (NETunnelProviderSession *)manager.connection; __block NSDictionary *counters = nil; - __block BOOL done = NO; + __block volatile atomic_bool done = false; NSError *messageError; BOOL sent = [session sendProviderMessage:(NSData *)[FBSocks5MsgStats dataUsingEncoding:NSUTF8StringEncoding] returnError:&messageError @@ -691,19 +786,21 @@ - (BOOL)lockedDisconnectWithError:(NSError **)error error:nil]; counters = [parsed isKindOfClass:NSDictionary.class] ? parsed : nil; } - done = YES; + atomic_store_explicit(&done, true, memory_order_release); }]; if (sent) { [[[[FBRunLoopSpinner new] timeout:statsBudget] interval:0.05] spinUntilTrue:^BOOL{ - return done; + return atomic_load_explicit(&done, memory_order_acquire); }]; } else { [FBLogger logFmt:@"socks5/stats: cannot query the tunnel extension: %@", messageError.localizedDescription]; } - // Counters stay zero when the extension cannot answer in time; stats polling must not fail. + // Counters stay zero when the extension cannot answer in time; do not read the result storage + // unless the acquire observed the callback's release publication. + NSDictionary *completedCounters = atomic_load_explicit(&done, memory_order_acquire) ? counters : nil; for (NSString *key in @[FBSocks5StatsKeyRxBytes, FBSocks5StatsKeyTxBytes, FBSocks5StatsKeyRxPackets, FBSocks5StatsKeyTxPackets]) { - NSNumber *value = counters[key]; + NSNumber *value = completedCounters[key]; if ([value isKindOfClass:NSNumber.class]) { stats[key] = value; } diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h index af74d791e9..4ded060842 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h @@ -30,6 +30,26 @@ extern NSString *const FBSocks5KeyUser; extern NSString *const FBSocks5KeyPass; /** @YES when DNS must be resolved through the proxy (socks5h://). */ extern NSString *const FBSocks5KeyRemoteDNS; +/** Remote WDA controller IP to exclude from the full-tunnel routes. */ +extern NSString *const FBSocks5KeyControlAddress; + +/** startVPNTunnelWithOptions key carrying the host's absolute whole-flow deadline. */ +extern NSString *const FBSocks5OptionStartupDeadline; +/** Startup budget used when the tunnel is launched outside the WDA connect route. */ +extern const NSTimeInterval FBSocks5DefaultStartupTimeout; + +/** + Resolves the provider's absolute startup deadline from host-supplied options, or applies the + default startup timeout when no valid deadline was supplied. + */ +NSDate *FBSocks5TunnelStartupDeadlineFromOptions(NSDictionary *_Nullable options, + NSDate *now); +/** Returns the nonnegative time remaining before `deadline`, capped to one operation's limit. */ +NSTimeInterval FBSocks5TunnelRemainingStartupTime(NSDate *deadline, NSDate *now, NSTimeInterval cap); +/** Validates the RFC 1929 username/password sub-negotiation reply. */ +BOOL FBSocks5TunnelUsernamePasswordAuthReplySucceeded(uint8_t version, uint8_t status); +/** Returns whether a proxy-selected authentication method was present in the client greeting. */ +BOOL FBSocks5TunnelAuthenticationMethodWasOffered(uint8_t method, BOOL hasCredentials); /** sendProviderMessage verb (UTF-8 encoded) asking the extension for traffic counters. */ extern NSString *const FBSocks5MsgStats; @@ -68,4 +88,21 @@ extern const NSUInteger FBSocks5TunnelMTU; */ NSString *FBSocks5HevConfigFromProviderConfiguration(NSDictionary *providerConfiguration); +/** + Coordinates packet-tunnel startup with stop requests so a stop cannot complete while startup + or network-settings cleanup is still pending. + */ +@interface FBSocks5TunnelStartupFence : NSObject + +@property (nonatomic, readonly, getter=isStopping) BOOL stopping; + +- (void)beginStartupWithCompletion:(void (^)(NSError *_Nullable error))completion; +- (BOOL)waitForSignal:(dispatch_semaphore_t)signal beforeDate:(NSDate *)deadline; +- (BOOL)performStartupActionIfNotStopping:(dispatch_block_t)block; +- (BOOL)requestStopWithCompletion:(dispatch_block_t)completion; +- (BOOL)finishStartupWithError:(nullable NSError *)error stoppedError:(NSError *)stoppedError; +- (void)finishStopCleanup; + +@end + NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m index 6500e3c594..a16f8dc6a8 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m @@ -13,6 +13,10 @@ NSString *const FBSocks5KeyUser = @"user"; NSString *const FBSocks5KeyPass = @"pass"; NSString *const FBSocks5KeyRemoteDNS = @"remoteDNS"; +NSString *const FBSocks5KeyControlAddress = @"controlAddress"; +NSString *const FBSocks5OptionStartupDeadline = @"startupDeadline"; + +const NSTimeInterval FBSocks5DefaultStartupTimeout = 30.0; NSString *const FBSocks5MsgStats = @"stats"; @@ -35,6 +39,143 @@ NSString *const FBSocks5TunnelMapDNSAddress = @"198.18.0.2"; const NSUInteger FBSocks5TunnelMTU = 8500; +NSDate *FBSocks5TunnelStartupDeadlineFromOptions(NSDictionary *_Nullable options, + NSDate *now) +{ + NSNumber *encodedDeadline = [options[FBSocks5OptionStartupDeadline] isKindOfClass:NSNumber.class] + ? (NSNumber *)options[FBSocks5OptionStartupDeadline] + : nil; + if (nil != encodedDeadline && isfinite(encodedDeadline.doubleValue)) { + return [NSDate dateWithTimeIntervalSinceReferenceDate:encodedDeadline.doubleValue]; + } + return [now dateByAddingTimeInterval:FBSocks5DefaultStartupTimeout]; +} + +NSTimeInterval FBSocks5TunnelRemainingStartupTime(NSDate *deadline, NSDate *now, NSTimeInterval cap) +{ + return MAX(0.0, MIN(cap, [deadline timeIntervalSinceDate:now])); +} + +BOOL FBSocks5TunnelUsernamePasswordAuthReplySucceeded(uint8_t version, uint8_t status) +{ + return 0x01 == version && 0x00 == status; +} + +BOOL FBSocks5TunnelAuthenticationMethodWasOffered(uint8_t method, BOOL hasCredentials) +{ + return 0x00 == method || (hasCredentials && 0x02 == method); +} + +@interface FBSocks5TunnelStartupFence () +@property (nonatomic) BOOL stopping; +@property (nonatomic) BOOL startupInProgress; +@property (nonatomic) BOOL stopCleanupInProgress; +@property (nonatomic, copy, nullable) void (^startupCompletion)(NSError *_Nullable error); +@property (nonatomic, strong) NSMutableArray *stopCompletions; +@end + +@implementation FBSocks5TunnelStartupFence + +- (BOOL)isStopping +{ + @synchronized (self) { + return _stopping; + } +} + +- (void)beginStartupWithCompletion:(void (^)(NSError *_Nullable))completion +{ + @synchronized (self) { + self.startupInProgress = YES; + self.startupCompletion = completion; + } +} + +- (BOOL)waitForSignal:(dispatch_semaphore_t)signal beforeDate:(NSDate *)deadline +{ + static const NSTimeInterval pollInterval = 0.05; + while (YES) { + if (self.isStopping) { + return NO; + } + NSTimeInterval remaining = deadline.timeIntervalSinceNow; + if (remaining <= 0) { + return NO; + } + dispatch_time_t waitUntil = dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(MIN(pollInterval, remaining) * NSEC_PER_SEC)); + if (0 == dispatch_semaphore_wait(signal, waitUntil)) { + return !self.isStopping; + } + } +} + +- (BOOL)performStartupActionIfNotStopping:(dispatch_block_t)block +{ + @synchronized (self) { + if (_stopping) { + return NO; + } + block(); + return YES; + } +} + +- (BOOL)requestStopWithCompletion:(dispatch_block_t)completion +{ + @synchronized (self) { + _stopping = YES; + if (nil == self.stopCompletions) { + self.stopCompletions = [NSMutableArray array]; + } + [self.stopCompletions addObject:[completion copy]]; + if (self.startupInProgress || self.stopCleanupInProgress) { + return NO; + } + self.stopCleanupInProgress = YES; + return YES; + } +} + +- (BOOL)finishStartupWithError:(nullable NSError *)error stoppedError:(NSError *)stoppedError +{ + void (^completion)(NSError *_Nullable) = nil; + NSError *completionError = nil; + BOOL shouldStartCleanup = NO; + @synchronized (self) { + if (!self.startupInProgress) { + return NO; + } + self.startupInProgress = NO; + completion = self.startupCompletion; + self.startupCompletion = nil; + completionError = _stopping && nil == error ? stoppedError : error; + if (_stopping && !self.stopCleanupInProgress && self.stopCompletions.count > 0) { + self.stopCleanupInProgress = YES; + shouldStartCleanup = YES; + } + } + if (nil != completion) { + completion(completionError); + } + return shouldStartCleanup; +} + +- (void)finishStopCleanup +{ + NSArray *completions; + @synchronized (self) { + completions = self.stopCompletions.copy; + [self.stopCompletions removeAllObjects]; + self.stopCleanupInProgress = NO; + } + for (dispatch_block_t completion in completions) { + completion(); + } +} + +@end + // YAML single-quoted scalar: the only escape is doubling embedded single quotes. static NSString *FBSocks5YAMLQuote(NSString *value) { diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.h b/WebDriverAgentLib/Utilities/FBSocks5URI.h index b88f716d7f..d063fa17cd 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5URI.h +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.h @@ -43,6 +43,9 @@ NS_ASSUME_NONNULL_BEGIN keyed by the FBSocks5Key* constants (credentials omitted when absent). */ - (NSDictionary *)providerConfiguration; +/** Adds the active WDA controller IP to the provider configuration when available. */ +- (NSDictionary *)providerConfigurationWithControlAddress:(nullable NSString *)controlAddress; + @end NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.m b/WebDriverAgentLib/Utilities/FBSocks5URI.m index a622153a12..f18b2db67e 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5URI.m +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.m @@ -22,6 +22,35 @@ static BOOL FBSocks5URISetError(NSError **error, NSString *message) return NO; } +static BOOL FBSocks5URIHasExplicitPort(NSString *uriString) +{ + NSRange schemeSeparator = [uriString rangeOfString:@"://"]; + if (NSNotFound == schemeSeparator.location) { + return NO; + } + NSUInteger authorityStart = NSMaxRange(schemeSeparator); + NSCharacterSet *authorityTerminators = [NSCharacterSet characterSetWithCharactersInString:@"/?#"]; + NSRange tail = NSMakeRange(authorityStart, uriString.length - authorityStart); + NSRange authorityEnd = [uriString rangeOfCharacterFromSet:authorityTerminators + options:(NSStringCompareOptions)0 + range:tail]; + NSUInteger authorityLength = NSNotFound == authorityEnd.location + ? uriString.length - authorityStart + : authorityEnd.location - authorityStart; + NSString *authority = [uriString substringWithRange:NSMakeRange(authorityStart, authorityLength)]; + NSRange userInfoSeparator = [authority rangeOfString:@"@" options:NSBackwardsSearch]; + NSString *hostAndPort = NSNotFound == userInfoSeparator.location + ? authority + : [authority substringFromIndex:NSMaxRange(userInfoSeparator)]; + if ([hostAndPort hasPrefix:@"["]) { + NSRange closingBracket = [hostAndPort rangeOfString:@"]"]; + return NSNotFound != closingBracket.location + && NSMaxRange(closingBracket) < hostAndPort.length + && ':' == [hostAndPort characterAtIndex:NSMaxRange(closingBracket)]; + } + return NSNotFound != [hostAndPort rangeOfString:@":" options:NSBackwardsSearch].location; +} + @interface FBSocks5URI () @property (nonatomic, copy) NSString *host; @property (nonatomic) NSUInteger port; @@ -42,8 +71,8 @@ + (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **) NSString *scheme = components.scheme.lowercaseString; BOOL remoteDNS = [scheme isEqualToString:@"socks5h"]; if (!remoteDNS && ![scheme isEqualToString:@"socks5"]) { - FBSocks5URISetError(error, [NSString stringWithFormat: - @"'%@' is not a valid SOCKS5 proxy URI. Expected socks5://[user:pass@]host[:port] or socks5h://… for remote DNS resolution", uriString]); + FBSocks5URISetError(error, + @"The value is not a valid SOCKS5 proxy URI. Expected socks5://[user:pass@]host[:port] or socks5h://… for remote DNS resolution"); return nil; } NSString *host = components.host; @@ -53,10 +82,14 @@ + (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **) host = [host substringWithRange:NSMakeRange(1, host.length - 2)]; } if (0 == host.length) { - FBSocks5URISetError(error, [NSString stringWithFormat:@"The socks5 URI '%@' must include a proxy host", uriString]); + FBSocks5URISetError(error, @"The socks5 URI must include a proxy host"); return nil; } NSUInteger port = FBSocks5DefaultPort; + if (nil == components.port && FBSocks5URIHasExplicitPort((NSString *)uriString)) { + FBSocks5URISetError(error, @"The socks5 URI contains an invalid proxy port"); + return nil; + } if (nil != components.port) { NSInteger rawPort = components.port.integerValue; if (rawPort <= 0 || rawPort > UINT16_MAX) { @@ -66,16 +99,28 @@ + (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **) port = (NSUInteger)rawPort; } + NSString *user = components.user.length > 0 ? components.user : nil; + NSString *pass = components.password.length > 0 ? components.password : nil; + if ((nil != components.user || nil != components.password) && (nil == user || nil == pass)) { + FBSocks5URISetError(error, @"The socks5 URI must include both a username and password, or neither"); + return nil; + } + FBSocks5URI *uri = [[self alloc] init]; uri.host = host; uri.port = port; - uri.user = components.user.length > 0 ? components.user : nil; - uri.pass = components.password.length > 0 ? components.password : nil; + uri.user = user; + uri.pass = pass; uri.remoteDNS = remoteDNS; return uri; } - (NSDictionary *)providerConfiguration +{ + return [self providerConfigurationWithControlAddress:nil]; +} + +- (NSDictionary *)providerConfigurationWithControlAddress:(nullable NSString *)controlAddress { NSMutableDictionary *config = [NSMutableDictionary dictionary]; config[FBSocks5KeyHost] = self.host; @@ -83,6 +128,9 @@ + (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **) config[FBSocks5KeyRemoteDNS] = @(self.remoteDNS); config[FBSocks5KeyUser] = self.user; config[FBSocks5KeyPass] = self.pass; + if (controlAddress.length > 0) { + config[FBSocks5KeyControlAddress] = controlAddress; + } return config.copy; } diff --git a/WebDriverAgentTests/UnitTests/FBRouteTests.m b/WebDriverAgentTests/UnitTests/FBRouteTests.m index f323abc1e1..0ef73f5f6d 100644 --- a/WebDriverAgentTests/UnitTests/FBRouteTests.m +++ b/WebDriverAgentTests/UnitTests/FBRouteTests.m @@ -14,6 +14,12 @@ @class RouteResponse; +extern BOOL FBSocks5ConnectTimeoutFromValue(id _Nullable value, NSTimeInterval *timeout); +extern NSArray *FBStandaloneRequestIdentity(NSString *method, + NSString *pathAndQuery, + NSData *body, + NSString *_Nullable clientAddress); + @interface FBHandlerMock : NSObject @property (nonatomic, assign) BOOL didCallSomeSelector; @end @@ -143,6 +149,68 @@ - (void)testStandaloneSurvivesRespondWithBlock XCTAssertTrue(route.isStandalone); } +- (void)testRequestDescriptionRedactsSocks5Credentials +{ + FBRouteRequest *request = [FBRouteRequest + routeRequestWithURL:[NSURL URLWithString:@"/mobilerun/socks5/connect"] + parameters:@{} + arguments:@{ + @"uri": @"socks5://alice:secret@proxy.example.com:1080", + @"timeout": @5, + }]; + + NSString *description = request.description; + XCTAssertFalse([description containsString:@"alice"]); + XCTAssertFalse([description containsString:@"secret"]); + XCTAssertTrue([description containsString:@"proxy.example.com:1080"]); + XCTAssertTrue([description containsString:@"timeout"]); + XCTAssertTrue([description containsString:@"5"]); +} + +- (void)testRequestDescriptionRedactsCredentialsFromMalformedProxyURIs +{ + for (NSString *uriString in @[ + @"socks5x://alice:secret@proxy.example.com:1080", + @" socks5://alice:secret@proxy.example.com:1080", + @"socks5:alice:secret@proxy.example.com", + ]) { + FBRouteRequest *request = [FBRouteRequest + routeRequestWithURL:[NSURL URLWithString:@"/mobilerun/socks5/connect"] + parameters:@{} + arguments:@{@"uri": uriString}]; + + NSString *description = request.description; + XCTAssertFalse([description containsString:@"alice"], @"%@", description); + XCTAssertFalse([description containsString:@"secret"], @"%@", description); + XCTAssertTrue([description containsString:@"proxy.example.com"], @"%@", description); + } +} + +- (void)testSocks5ConnectTimeoutIsFiniteAndBounded +{ + NSTimeInterval timeout = 0; + XCTAssertTrue(FBSocks5ConnectTimeoutFromValue(nil, &timeout)); + XCTAssertEqual(timeout, 30.0); + XCTAssertTrue(FBSocks5ConnectTimeoutFromValue(@300, &timeout)); + XCTAssertEqual(timeout, 300.0); + + for (NSNumber *invalid in @[@0, @(-1), @301, @(1e308), @(INFINITY), @(NAN), @YES]) { + XCTAssertFalse(FBSocks5ConnectTimeoutFromValue(invalid, &timeout), @"%@ should be rejected", invalid); + } + XCTAssertFalse(FBSocks5ConnectTimeoutFromValue(@"30", &timeout)); +} + +- (void)testStandaloneRequestIdentityIncludesControllerAddress +{ + NSData *body = [@"same" dataUsingEncoding:NSUTF8StringEncoding]; + NSArray *first = FBStandaloneRequestIdentity(@"POST", @"/mobilerun/socks5/connect", body, @"192.0.2.10"); + NSArray *second = FBStandaloneRequestIdentity(@"POST", @"/mobilerun/socks5/connect", body, @"192.0.2.11"); + NSArray *sameAsFirst = FBStandaloneRequestIdentity(@"POST", @"/mobilerun/socks5/connect", body, @"192.0.2.10"); + + XCTAssertNotEqualObjects(first, second); + XCTAssertEqualObjects(first, sameAsFirst); +} + + (id)dummyHandler:(FBRouteRequest *)request { return nil; @@ -157,6 +225,7 @@ - (void)testStandaloneSurvivesRespondWithBlock static atomic_bool gControlProbeDone; static atomic_bool gControlProbeRanOffMain; +static NSString *gControlProbeClientAddress; static atomic_bool gAutomationProbeDone; static atomic_bool gAutomationProbeRanOnMain; static atomic_int gSpinningProbeDepth; @@ -164,6 +233,9 @@ - (void)testStandaloneSurvivesRespondWithBlock static atomic_int gSpinningProbeCompletions; @interface FBWebServer (DispatchTests) ++ (dispatch_queue_t)automationFunnelQueue; ++ (BOOL)performAutomationBlockOnMainQueue:(dispatch_block_t)block + beforeDate:(NSDate *)deadline; - (void)registerRouteHandlers:(NSArray *)commandHandlerClasses; - (void)registerServerKeyRouteHandlers; - (FBHTTPServer *)server; @@ -184,6 +256,7 @@ + (NSArray *)routes { return @[ [[FBRoute GET:@"/probe/control"].withoutSession.standalone respondWithBlock:^ id (FBRouteRequest *request) { + gControlProbeClientAddress = request.clientAddress; atomic_store(&gControlProbeRanOffMain, !NSThread.isMainThread); atomic_store(&gControlProbeDone, true); return FBResponseWithOK(); @@ -225,6 +298,7 @@ - (void)setUp [super setUp]; atomic_store(&gControlProbeDone, false); atomic_store(&gControlProbeRanOffMain, false); + gControlProbeClientAddress = nil; atomic_store(&gAutomationProbeDone, false); atomic_store(&gAutomationProbeRanOnMain, false); atomic_store(&gSpinningProbeDepth, 0); @@ -312,6 +386,7 @@ - (void)testControlRouteRespondsWhileMainThreadIsBusy } XCTAssertTrue(atomic_load(&gControlProbeDone)); XCTAssertTrue(atomic_load(&gControlProbeRanOffMain)); + XCTAssertEqualObjects(gControlProbeClientAddress, @"127.0.0.1"); } - (void)testAutomationRouteRunsOnMainQueue @@ -370,6 +445,88 @@ - (void)testAutomationRequestsDoNotNestInsideRunLoopSpin XCTAssertEqual(atomic_load(&gSpinningProbeMaxDepth), 1, @"a second automation request must never nest inside the first"); } +- (void)testAutomationFunnelDeadlineCancelsQueuedBlock +{ + dispatch_semaphore_t funnelEntered = dispatch_semaphore_create(0); + dispatch_semaphore_t releaseFunnel = dispatch_semaphore_create(0); + dispatch_async(FBWebServer.automationFunnelQueue, ^{ + dispatch_semaphore_signal(funnelEntered); + dispatch_semaphore_wait(releaseFunnel, DISPATCH_TIME_FOREVER); + }); + XCTAssertEqual(dispatch_semaphore_wait(funnelEntered, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0L); + + __block volatile atomic_bool blockRan; + atomic_init(&blockRan, false); + __block BOOL acquired = YES; + __block NSTimeInterval elapsed = 0; + XCTestExpectation *returned = [self expectationWithDescription:@"deadline-aware funnel acquisition returned"]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + NSDate *startedAt = NSDate.date; + acquired = [FBWebServer performAutomationBlockOnMainQueue:^{ + atomic_store(&blockRan, true); + } beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + elapsed = -startedAt.timeIntervalSinceNow; + [returned fulfill]; + }); + + [self waitForExpectations:@[returned] timeout:1.0]; + XCTAssertFalse(acquired); + XCTAssertLessThan(elapsed, 0.75); + XCTAssertFalse(atomic_load(&blockRan)); + + XCTestExpectation *funnelDrained = [self expectationWithDescription:@"cancelled funnel block drained"]; + dispatch_semaphore_signal(releaseFunnel); + dispatch_async(FBWebServer.automationFunnelQueue, ^{ + [funnelDrained fulfill]; + }); + [self waitForExpectations:@[funnelDrained] timeout:1.0]; + XCTAssertFalse(atomic_load(&blockRan), @"an expired queued block must not execute later"); +} + +- (void)testAutomationFunnelRethrowsExceptionOnCallerQueue +{ + NSException *expectedException = [NSException exceptionWithName:@"FBExpectedAutomationException" + reason:@"synthetic XCUI failure" + userInfo:nil]; + __block NSException *caughtException = nil; + XCTestExpectation *returned = [self expectationWithDescription:@"automation exception returned to caller queue"]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + @try { + [FBWebServer performAutomationBlockOnMainQueue:^{ + @throw expectedException; + }]; + } @catch (NSException *exception) { + caughtException = exception; + } + [returned fulfill]; + }); + + [self waitForExpectations:@[returned] timeout:2.0]; + XCTAssertEqual(caughtException, expectedException); +} + +- (void)testDeadlineAutomationFunnelRethrowsExceptionOnCallerQueue +{ + NSException *expectedException = [NSException exceptionWithName:@"FBExpectedDeadlineAutomationException" + reason:@"synthetic consent query failure" + userInfo:nil]; + __block NSException *caughtException = nil; + XCTestExpectation *returned = [self expectationWithDescription:@"deadline automation exception returned to caller queue"]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + @try { + [FBWebServer performAutomationBlockOnMainQueue:^{ + @throw expectedException; + } beforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; + } @catch (NSException *exception) { + caughtException = exception; + } + [returned fulfill]; + }); + + [self waitForExpectations:@[returned] timeout:2.0]; + XCTAssertEqual(caughtException, expectedException); +} + @end #import @@ -377,6 +534,9 @@ - (void)testAutomationRequestsDoNotNestInsideRunLoopSpin #import static atomic_int gFramingProbeHits; +static atomic_int gStandaloneBodyProbeHits; +static dispatch_semaphore_t gStandaloneFirstBodyEntered; +static dispatch_semaphore_t gStandaloneReleaseFirstBody; // Exercises FBHTTPServer's HTTP framing defenses with raw socket data that URL-loading APIs // cannot produce: malformed Content-Length values and header blocks that never terminate. @@ -391,6 +551,9 @@ - (void)setUp { [super setUp]; atomic_store(&gFramingProbeHits, 0); + atomic_store(&gStandaloneBodyProbeHits, 0); + gStandaloneFirstBodyEntered = dispatch_semaphore_create(0); + gStandaloneReleaseFirstBody = dispatch_semaphore_create(0); self.server = [FBHTTPServer new]; [self.server handleMethod:@"POST" withPath:@"/framing/probe" block:^(RouteRequest *request, RouteResponse *response) { atomic_fetch_add(&gFramingProbeHits, 1); @@ -403,6 +566,15 @@ - (void)setUp atomic_fetch_add(&gFramingProbeHits, 1); [response respondWithString:@"session-probe-ok"]; }]; + [self.server handleMethod:@"POST" withPath:@"/standalone/body" standalone:YES block:^(RouteRequest *request, RouteResponse *response) { + atomic_fetch_add(&gStandaloneBodyProbeHits, 1); + NSString *body = [[NSString alloc] initWithData:request.body encoding:NSUTF8StringEncoding] ?: @""; + if ([body isEqualToString:@"first"]) { + dispatch_semaphore_signal(gStandaloneFirstBodyEntered); + dispatch_semaphore_wait(gStandaloneReleaseFirstBody, DISPATCH_TIME_FOREVER); + } + [response respondWithString:body]; + }]; self.server.port = 0; NSError *error; XCTAssertTrue([self.server start:&error], @"%@", error); @@ -416,6 +588,43 @@ - (void)tearDown [super tearDown]; } +- (void)testStandaloneRequestsWithDifferentBodiesAreNotCoalesced +{ + NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"http://127.0.0.1:%d/standalone/body", self.port]]; + NSMutableURLRequest *firstRequest = [NSMutableURLRequest requestWithURL:url]; + firstRequest.HTTPMethod = @"POST"; + firstRequest.HTTPBody = [@"first" dataUsingEncoding:NSUTF8StringEncoding]; + NSMutableURLRequest *secondRequest = [NSMutableURLRequest requestWithURL:url]; + secondRequest.HTTPMethod = @"POST"; + secondRequest.HTTPBody = [@"second" dataUsingEncoding:NSUTF8StringEncoding]; + + XCTestExpectation *firstResponse = [self expectationWithDescription:@"first standalone response"]; + XCTestExpectation *secondResponse = [self expectationWithDescription:@"second standalone response"]; + __block NSString *firstBody = nil; + __block NSString *secondBody = nil; + [[NSURLSession.sharedSession dataTaskWithRequest:firstRequest completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + firstBody = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + [firstResponse fulfill]; + }] resume]; + XCTAssertEqual(0, dispatch_semaphore_wait(gStandaloneFirstBodyEntered, + dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC))); + + [[NSURLSession.sharedSession dataTaskWithRequest:secondRequest completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + secondBody = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + [secondResponse fulfill]; + }] resume]; + NSDate *secondHandlerDeadline = [NSDate dateWithTimeIntervalSinceNow:1.0]; + while (atomic_load(&gStandaloneBodyProbeHits) < 2 && secondHandlerDeadline.timeIntervalSinceNow > 0) { + [NSThread sleepForTimeInterval:0.01]; + } + dispatch_semaphore_signal(gStandaloneReleaseFirstBody); + + [self waitForExpectations:@[firstResponse, secondResponse] timeout:5.0]; + XCTAssertEqual(atomic_load(&gStandaloneBodyProbeHits), 2); + XCTAssertEqualObjects(firstBody, @"first"); + XCTAssertEqualObjects(secondBody, @"second"); +} + // Sends `payload` as-is and reads until the server closes the connection or `timeout` elapses. // Returns everything received (nil on connect failure); *didClose reports whether EOF was seen. - (NSString *)responseForRawPayload:(NSData *)payload timeout:(NSTimeInterval)timeout didClose:(BOOL *)didClose diff --git a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m index 6248057195..c8ec542133 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m @@ -12,6 +12,16 @@ #import "FBSocks5TunnelProtocol.h" #import "FBSocks5URI.h" +@interface FBSocks5LifecycleGuard : NSObject +@property (atomic, strong, nullable) dispatch_semaphore_t pendingSaveSignal; +- (BOOL)performLockedWithDeadline:(nullable NSDate *)deadline + block:(NS_NOESCAPE dispatch_block_t)block; +- (BOOL)fencePendingSaveWithDeadline:(nullable NSDate *)deadline error:(NSError **)error; +@end + +extern FBSocks5TunnelManagerError FBSocks5TunnelManagerSaveFailureCode(BOOL completed); +extern NSDictionary *_Nullable FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(NSBundle *bundle); + @interface FBSocks5ConfigTests : XCTestCase @property (nonatomic, nullable, copy) NSString *tempBundleRoot; @end @@ -76,6 +86,16 @@ - (void)testYAMLEnablesMapDNSForRemoteResolution XCTAssertTrue([yaml containsString:dnsLine]); } +- (void)testProviderConfigurationCarriesControlAddress +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://proxy.example.com:1080" error:&error]; + XCTAssertNotNil(uri, @"%@", error); + + NSDictionary *config = [uri providerConfigurationWithControlAddress:@"192.0.2.20"]; + XCTAssertEqualObjects(config[FBSocks5KeyControlAddress], @"192.0.2.20"); +} + #pragma mark - Tunnel extension presence - (NSBundle *)makeFakeRunnerBundleWithTunnelAppex:(BOOL)withAppex @@ -123,4 +143,195 @@ - (void)testTunnelExtensionNotDetectedWithoutAppex XCTAssertFalse([FBSocks5TunnelManager isTunnelExtensionEmbeddedInBundle:bundle]); } +- (void)testMissingTunnelExtensionProducesDisconnectedNoOpSnapshot +{ + NSBundle *bundleWithoutExtension = [self makeFakeRunnerBundleWithTunnelAppex:NO]; + NSDictionary *snapshot = + FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(bundleWithoutExtension); + + XCTAssertEqualObjects(snapshot[FBSocks5StatsKeyConnected], @NO); + XCTAssertEqualObjects(snapshot[FBSocks5StatsKeyRxBytes], @0); + XCTAssertEqualObjects(snapshot[FBSocks5StatsKeyTxBytes], @0); +} + +- (void)testEmbeddedTunnelExtensionDoesNotShortCircuitDisconnect +{ + NSBundle *bundleWithExtension = [self makeFakeRunnerBundleWithTunnelAppex:YES]; + XCTAssertNil(FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(bundleWithExtension)); +} + +#pragma mark - Lifecycle serialization + +- (void)testLifecycleLockAcquisitionExpiresWithoutRunningQueuedBlockLater +{ + FBSocks5LifecycleGuard *guard = [[FBSocks5LifecycleGuard alloc] init]; + dispatch_semaphore_t lockHeld = dispatch_semaphore_create(0); + dispatch_semaphore_t releaseLock = dispatch_semaphore_create(0); + dispatch_semaphore_t holderDone = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + [guard performLockedWithDeadline:nil block:^{ + dispatch_semaphore_signal(lockHeld); + dispatch_semaphore_wait(releaseLock, DISPATCH_TIME_FOREVER); + }]; + dispatch_semaphore_signal(holderDone); + }); + XCTAssertEqual(0, dispatch_semaphore_wait(lockHeld, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC))); + + __block BOOL queuedBlockRan = NO; + NSDate *startedAt = [NSDate date]; + BOOL acquired = [guard performLockedWithDeadline:[NSDate dateWithTimeIntervalSinceNow:0.05] + block:^{ + queuedBlockRan = YES; + }]; + NSTimeInterval elapsed = -startedAt.timeIntervalSinceNow; + + XCTAssertFalse(acquired); + XCTAssertFalse(queuedBlockRan); + XCTAssertLessThan(elapsed, 0.2); + dispatch_semaphore_signal(releaseLock); + XCTAssertEqual(0, dispatch_semaphore_wait(holderDone, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC))); + XCTAssertFalse(queuedBlockRan); +} + +- (void)testTimedOutPendingSaveFenceRemainsArmedUntilSaveCompletes +{ + FBSocks5LifecycleGuard *guard = [[FBSocks5LifecycleGuard alloc] init]; + dispatch_semaphore_t saveSignal = dispatch_semaphore_create(0); + guard.pendingSaveSignal = saveSignal; + + NSError *error; + XCTAssertFalse([guard fencePendingSaveWithDeadline:[NSDate dateWithTimeIntervalSinceNow:0.03] + error:&error]); + XCTAssertEqual(error.code, FBSocks5TunnelManagerErrorTimeout); + XCTAssertEqual(guard.pendingSaveSignal, saveSignal); + + dispatch_semaphore_signal(saveSignal); + error = nil; + XCTAssertTrue([guard fencePendingSaveWithDeadline:[NSDate dateWithTimeIntervalSinceNow:0.1] + error:&error]); + XCTAssertNil(error); + XCTAssertNil(guard.pendingSaveSignal); +} + +- (void)testTunnelStopWaitsForPendingStartupAndSettingsCleanup +{ + FBSocks5TunnelStartupFence *fence = [[FBSocks5TunnelStartupFence alloc] init]; + __block NSError *startupError = nil; + __block BOOL stopCompleted = NO; + [fence beginStartupWithCompletion:^(NSError *error) { + startupError = error; + }]; + + XCTAssertFalse([fence requestStopWithCompletion:^{ + stopCompleted = YES; + }]); + __block BOOL lateStartupActionRan = NO; + XCTAssertFalse([fence performStartupActionIfNotStopping:^{ + lateStartupActionRan = YES; + }]); + + NSError *stoppedError = [NSError errorWithDomain:@"test" code:1 userInfo:nil]; + XCTAssertTrue([fence finishStartupWithError:nil stoppedError:stoppedError]); + XCTAssertEqual(startupError, stoppedError); + XCTAssertFalse(lateStartupActionRan); + XCTAssertFalse(stopCompleted, @"stop must wait until network settings have been cleared"); + + [fence finishStopCleanup]; + XCTAssertTrue(stopCompleted); +} + +- (void)testTunnelStartupDeadlineUsesOneHostSuppliedBudget +{ + NSDate *now = [NSDate dateWithTimeIntervalSinceReferenceDate:1000]; + NSDate *hostDeadline = [NSDate dateWithTimeIntervalSinceReferenceDate:1005]; + NSDictionary *options = @{ + FBSocks5OptionStartupDeadline: @(hostDeadline.timeIntervalSinceReferenceDate), + }; + + NSDate *deadline = FBSocks5TunnelStartupDeadlineFromOptions(options, now); + + XCTAssertEqualObjects(deadline, hostDeadline); + XCTAssertEqualWithAccuracy(FBSocks5TunnelRemainingStartupTime(deadline, + [now dateByAddingTimeInterval:3], + 8.0), + 2.0, 0.001); + XCTAssertEqual(FBSocks5TunnelRemainingStartupTime(deadline, + [now dateByAddingTimeInterval:6], + 8.0), + 0.0); +} + +- (void)testTunnelStartupDeadlineDefaultsWhenHostOptionIsAbsent +{ + NSDate *now = [NSDate dateWithTimeIntervalSinceReferenceDate:1000]; + + NSDate *deadline = FBSocks5TunnelStartupDeadlineFromOptions(nil, now); + + XCTAssertEqualWithAccuracy([deadline timeIntervalSinceDate:now], + FBSocks5DefaultStartupTimeout, + 0.001); +} + +- (void)testTunnelStartupSignalWaitStopsAtDeadline +{ + FBSocks5TunnelStartupFence *fence = [[FBSocks5TunnelStartupFence alloc] init]; + dispatch_semaphore_t lateSignal = dispatch_semaphore_create(0); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + dispatch_semaphore_signal(lateSignal); + }); + + NSDate *startedAt = [NSDate date]; + XCTAssertFalse([fence waitForSignal:lateSignal + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.03]]); + XCTAssertLessThan(-startedAt.timeIntervalSinceNow, 0.25); +} + +- (void)testTunnelStartupSignalWaitCancelsForStop +{ + FBSocks5TunnelStartupFence *fence = [[FBSocks5TunnelStartupFence alloc] init]; + dispatch_semaphore_t neverSignal = dispatch_semaphore_create(0); + __block BOOL stopCompleted = NO; + [fence beginStartupWithCompletion:^(NSError *error) {}]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.03 * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + [fence requestStopWithCompletion:^{ + stopCompleted = YES; + }]; + }); + + NSDate *startedAt = [NSDate date]; + XCTAssertFalse([fence waitForSignal:neverSignal + beforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]); + XCTAssertLessThan(-startedAt.timeIntervalSinceNow, 0.3); + NSError *stoppedError = [NSError errorWithDomain:@"test" code:1 userInfo:nil]; + XCTAssertTrue([fence finishStartupWithError:nil stoppedError:stoppedError]); + XCTAssertFalse(stopCompleted); + [fence finishStopCleanup]; + XCTAssertTrue(stopCompleted); +} + +- (void)testUsernamePasswordAuthReplyRequiresExpectedVersionAndSuccessStatus +{ + XCTAssertTrue(FBSocks5TunnelUsernamePasswordAuthReplySucceeded(0x01, 0x00)); + XCTAssertFalse(FBSocks5TunnelUsernamePasswordAuthReplySucceeded(0x00, 0x00)); + XCTAssertFalse(FBSocks5TunnelUsernamePasswordAuthReplySucceeded(0x02, 0x00)); + XCTAssertFalse(FBSocks5TunnelUsernamePasswordAuthReplySucceeded(0x01, 0x01)); +} + +- (void)testProxyMayOnlySelectAnAuthenticationMethodOfferedByTheClient +{ + XCTAssertTrue(FBSocks5TunnelAuthenticationMethodWasOffered(0x00, NO)); + XCTAssertFalse(FBSocks5TunnelAuthenticationMethodWasOffered(0x02, NO)); + XCTAssertTrue(FBSocks5TunnelAuthenticationMethodWasOffered(0x00, YES)); + XCTAssertTrue(FBSocks5TunnelAuthenticationMethodWasOffered(0x02, YES)); + XCTAssertFalse(FBSocks5TunnelAuthenticationMethodWasOffered(0x01, YES)); +} + +- (void)testUnfinishedPreferencesSaveIsClassifiedAsTimeout +{ + XCTAssertEqual(FBSocks5TunnelManagerSaveFailureCode(NO), FBSocks5TunnelManagerErrorTimeout); + XCTAssertEqual(FBSocks5TunnelManagerSaveFailureCode(YES), FBSocks5TunnelManagerErrorNotAuthorized); +} + @end diff --git a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m index 2b14a45aa0..1fe65a1532 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m @@ -86,7 +86,8 @@ - (void)testRejectsMissingHost - (void)testRejectsInvalidInput { - for (NSString *bad in @[@"", @"not a uri at all", @"socks5://h:port", @"socks5://h:70000"]) { + for (NSString *bad in @[@"", @"not a uri at all", @"socks5://h:port", @"socks5://h:70000", + @"socks5://proxy:", @"socks5://proxy:18446744073709551616"]) { NSError *error; XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); XCTAssertNotNil(error, @"'%@' should produce an error", bad); @@ -96,6 +97,32 @@ - (void)testRejectsInvalidInput XCTAssertNotNil(error); } +- (void)testRejectsIncompleteCredentials +{ + for (NSString *bad in @[@"socks5://user@proxy", @"socks5://:pass@proxy"]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); + XCTAssertNotNil(error, @"'%@' should produce an error", bad); + } +} + +- (void)testParseErrorsDoNotExposeCredentials +{ + NSDictionary *invalidURIs = @{ + @"http://alice:secret@proxy": @"not a valid SOCKS5 proxy URI", + @"socks5://alice:secret@": @"must include a proxy host", + @"socks5://alice:secret@proxy:": @"invalid proxy port", + }; + [invalidURIs enumerateKeysAndObjectsUsingBlock:^(NSString *uriString, NSString *reason, BOOL *stop) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:uriString error:&error]); + XCTAssertNotNil(error); + XCTAssertTrue([error.localizedDescription containsString:reason], @"%@", error); + XCTAssertFalse([error.localizedDescription containsString:@"alice"], @"%@", error); + XCTAssertFalse([error.localizedDescription containsString:@"secret"], @"%@", error); + }]; +} + - (void)testProviderConfigurationContainsConnectionDetails { FBSocks5URI *uri = [FBSocks5URI parse:@"socks5h://user:pass@1.2.3.4:9050" error:nil]; diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m index fbb8bd3606..4c9c777ace 100644 --- a/WebDriverAgentTunnel/FBTunnelPacketProvider.m +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -12,6 +12,7 @@ #include #include #include +#include #import "FBSocks5TunnelProtocol.h" #import "FBTunFdFinder.h" @@ -26,6 +27,8 @@ userInfo:@{NSLocalizedDescriptionKey: message}]; } +static NSString *_Nullable FBTunnelNormalizedIPAddress(NSString *_Nullable address, BOOL *isIPv6); + // Resolves a proxy host to its literal IPs, IPv4 first (the engine's preferred family), each // family preserving resolver order, without duplicates. The engine must not resolve the host // itself: once the tunnel's DNS settings are active, an in-provider lookup would be routed @@ -33,7 +36,7 @@ // just the first per family, so the pre-flight can fail over to later A/AAAA records when the // first one is unreachable. An entry's family is recoverable from the literal itself (IPv6 // literals contain ':'). -static NSArray *FBTunnelResolveHostAddresses(NSString *host) +static NSArray *FBTunnelResolveHostAddressesBlocking(NSString *host) { struct addrinfo hints; memset(&hints, 0, sizeof(hints)); @@ -69,31 +72,191 @@ return [ipv4 arrayByAddingObjectsFromArray:ipv6]; } +static dispatch_queue_t FBTunnelResolverQueue(void) +{ + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.facebook.WebDriverAgent.socks5-resolver", + dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, + QOS_CLASS_UTILITY, 0)); + }); + return queue; +} + +static NSArray *_Nullable FBTunnelResolveHostAddresses(NSString *host, + NSDate *deadline, + FBSocks5TunnelStartupFence *startupFence, + BOOL *completed) +{ + BOOL literalIsIPv6 = NO; + NSString *literal = FBTunnelNormalizedIPAddress(host, &literalIsIPv6); + if (nil != literal) { + *completed = YES; + return @[literal]; + } + if (startupFence.isStopping || deadline.timeIntervalSinceNow <= 0) { + *completed = NO; + return nil; + } + __block NSArray *addresses = nil; + __block volatile atomic_bool cancelled = false; + dispatch_semaphore_t resolved = dispatch_semaphore_create(0); + dispatch_async(FBTunnelResolverQueue(), ^{ + if (atomic_load_explicit(&cancelled, memory_order_acquire)) { + return; + } + @autoreleasepool { + NSArray *result = FBTunnelResolveHostAddressesBlocking(host); + if (atomic_load_explicit(&cancelled, memory_order_acquire)) { + return; + } + addresses = result; + dispatch_semaphore_signal(resolved); + } + }); + if (![startupFence waitForSignal:resolved beforeDate:deadline]) { + atomic_store_explicit(&cancelled, true, memory_order_release); + *completed = NO; + return nil; + } + *completed = YES; + return addresses; +} + +static NSString *_Nullable FBTunnelNormalizedIPAddress(NSString *_Nullable address, BOOL *isIPv6) +{ + if (0 == address.length) { + return nil; + } + NSString *candidate = address; + NSRange scopeSeparator = [candidate rangeOfString:@"%"]; + if (NSNotFound != scopeSeparator.location) { + candidate = [candidate substringToIndex:scopeSeparator.location]; + } + struct in_addr ipv4; + if (1 == inet_pton(AF_INET, candidate.UTF8String, &ipv4)) { + *isIPv6 = NO; + return candidate; + } + struct in6_addr ipv6; + if (1 == inet_pton(AF_INET6, candidate.UTF8String, &ipv6)) { + *isIPv6 = YES; + return candidate; + } + return nil; +} + /// How long the pre-flight SOCKS5 handshake may take before the proxy counts as unreachable. static const NSTimeInterval FBTunnelProbeTimeout = 8.0; +/// Poll in short slices so stop requests cancel an in-flight connect or handshake promptly. +static const NSTimeInterval FBTunnelProbePollInterval = 0.1; /// Grace period for the engine to fail its own initialization before startup is declared good. static const NSTimeInterval FBTunnelEngineSettleTimeout = 0.75; -static BOOL FBTunnelSetSocketTimeout(int fd, NSTimeInterval seconds) +typedef NS_ENUM(NSUInteger, FBTunnelProbeIOResult) { + FBTunnelProbeIOResultSuccess, + FBTunnelProbeIOResultTransportFailure, + FBTunnelProbeIOResultCandidateTimeout, + FBTunnelProbeIOResultStartupTimeout, + FBTunnelProbeIOResultStopped, +}; + +static FBTunnelProbeIOResult FBTunnelWaitForSocket(int fd, short events, + NSDate *candidateDeadline, + NSDate *startupDeadline, + FBSocks5TunnelStartupFence *startupFence) { - struct timeval tv; - tv.tv_sec = (time_t)seconds; - tv.tv_usec = (suseconds_t)((seconds - (NSTimeInterval)tv.tv_sec) * 1000000); - return 0 == setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) - && 0 == setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + while (YES) { + if (startupFence.isStopping) { + return FBTunnelProbeIOResultStopped; + } + NSDate *now = [NSDate date]; + NSTimeInterval startupRemaining = [startupDeadline timeIntervalSinceDate:now]; + if (startupRemaining <= 0) { + return FBTunnelProbeIOResultStartupTimeout; + } + NSTimeInterval candidateRemaining = [candidateDeadline timeIntervalSinceDate:now]; + if (candidateRemaining <= 0) { + return FBTunnelProbeIOResultCandidateTimeout; + } + NSTimeInterval wait = MIN(FBTunnelProbePollInterval, + MIN(startupRemaining, candidateRemaining)); + struct pollfd pfd; + memset(&pfd, 0, sizeof(pfd)); + pfd.fd = fd; + pfd.events = events; + int result = poll(&pfd, 1, MAX(1, (int)(wait * 1000))); + if (result > 0) { + return FBTunnelProbeIOResultSuccess; + } + if (result < 0 && EINTR != errno) { + return FBTunnelProbeIOResultTransportFailure; + } + } +} + +static FBTunnelProbeIOResult FBTunnelWriteFully(int fd, const void *bytes, size_t length, + NSDate *candidateDeadline, + NSDate *startupDeadline, + FBSocks5TunnelStartupFence *startupFence) +{ + size_t sent = 0; + while (sent < length) { + FBTunnelProbeIOResult waitResult = FBTunnelWaitForSocket(fd, POLLOUT, candidateDeadline, + startupDeadline, startupFence); + if (FBTunnelProbeIOResultSuccess != waitResult) { + return waitResult; + } + ssize_t count = send(fd, (const uint8_t *)bytes + sent, length - sent, 0); + if (count > 0) { + sent += (size_t)count; + continue; + } + if (count < 0 && (EINTR == errno || EAGAIN == errno || EWOULDBLOCK == errno)) { + continue; + } + return FBTunnelProbeIOResultTransportFailure; + } + return FBTunnelProbeIOResultSuccess; } -static BOOL FBTunnelReadFully(int fd, uint8_t *buffer, size_t length) +static FBTunnelProbeIOResult FBTunnelReadFully(int fd, void *bytes, size_t length, + NSDate *candidateDeadline, + NSDate *startupDeadline, + FBSocks5TunnelStartupFence *startupFence) { - size_t got = 0; - while (got < length) { - ssize_t n = recv(fd, buffer + got, length - got, 0); - if (n <= 0) { - return NO; + size_t received = 0; + while (received < length) { + FBTunnelProbeIOResult waitResult = FBTunnelWaitForSocket(fd, POLLIN, candidateDeadline, + startupDeadline, startupFence); + if (FBTunnelProbeIOResultSuccess != waitResult) { + return waitResult; } - got += (size_t)n; + ssize_t count = recv(fd, (uint8_t *)bytes + received, length - received, 0); + if (count > 0) { + received += (size_t)count; + continue; + } + if (count < 0 && (EINTR == errno || EAGAIN == errno || EWOULDBLOCK == errno)) { + continue; + } + return FBTunnelProbeIOResultTransportFailure; + } + return FBTunnelProbeIOResultSuccess; +} + +static NSError *FBTunnelProbeError(FBTunnelProbeIOResult result, NSString *transportMessage, + BOOL *outRetryable) +{ + if (FBTunnelProbeIOResultStopped == result) { + return FBTunnelError(@"The tunnel was stopped while it was still starting up"); + } + if (FBTunnelProbeIOResultStartupTimeout == result) { + return FBTunnelError(@"Timed out while starting the SOCKS5 tunnel"); } - return YES; + *outRetryable = YES; + return FBTunnelError(transportMessage); } /** @@ -105,18 +268,20 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia packet routed into the tunnel would be silently blackholed. Runs before the tunnel's network settings are applied, so it cannot be captured by the tunnel it is validating. - `outUnreachable` is set when the failure happened before any SOCKS5 byte was exchanged - (socket/connect failure): only those failures are worth retrying on another resolved address, - while a protocol or credential rejection comes from the proxy itself and would repeat there. + `outRetryable` is set for transport failures (connect, timeout, or EOF) that may be specific to + one resolved backend. Protocol and credential rejections are terminal because the proxy itself + answered them and they should repeat on the other addresses for that service. */ static NSError *_Nullable FBTunnelProbeSocks5(NSString *proxyIP, BOOL isIPv6, uint16_t port, NSString *_Nullable user, NSString *_Nullable pass, - BOOL *outUnreachable) + NSDate *candidateDeadline, NSDate *startupDeadline, + FBSocks5TunnelStartupFence *startupFence, + BOOL *outRetryable) { - *outUnreachable = NO; + *outRetryable = NO; int fd = socket(isIPv6 ? AF_INET6 : AF_INET, SOCK_STREAM, 0); if (fd < 0) { - *outUnreachable = YES; + *outRetryable = YES; return FBTunnelError(@"Cannot create a socket to probe the SOCKS5 proxy"); } // A proxy that closes the connection mid-handshake makes send() raise SIGPIPE on Darwin, @@ -124,14 +289,15 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia // ignore, but that happens later - this probe runs before it, so opt out per socket. int noSigPipe = 1; setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)); - FBTunnelSetSocketTimeout(fd, FBTunnelProbeTimeout); - // SO_RCVTIMEO/SO_SNDTIMEO do not bound connect(); a blocking connect to a silently filtered - // endpoint would hang for the system's SYN-retry window (~75s) - per candidate address, now - // that several may be probed. Connect non-blocking under the probe deadline instead, then - // restore blocking mode so the handshake keeps relying on the socket timeouts above. + // Keep the socket non-blocking throughout connect and handshake. Short poll slices make both + // the per-candidate budget and a concurrent stop request observable inside every I/O wait. int flags = fcntl(fd, F_GETFL, 0); - fcntl(fd, F_SETFL, flags | O_NONBLOCK); + if (flags < 0 || 0 != fcntl(fd, F_SETFL, flags | O_NONBLOCK)) { + close(fd); + *outRetryable = YES; + return FBTunnelError(@"Cannot make the SOCKS5 probe socket non-blocking"); + } int connected = -1; if (isIPv6) { @@ -156,11 +322,9 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia connected = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); } if (0 != connected && EINPROGRESS == errno) { - struct pollfd pfd; - memset(&pfd, 0, sizeof(pfd)); - pfd.fd = fd; - pfd.events = POLLOUT; - if (1 == poll(&pfd, 1, (int)(FBTunnelProbeTimeout * 1000))) { + FBTunnelProbeIOResult waitResult = FBTunnelWaitForSocket(fd, POLLOUT, candidateDeadline, + startupDeadline, startupFence); + if (FBTunnelProbeIOResultSuccess == waitResult) { int socketError = 0; socklen_t errorLength = sizeof(socketError); if (0 == getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketError, &errorLength) && 0 == socketError) { @@ -169,17 +333,20 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia errno = 0 != socketError ? socketError : ETIMEDOUT; } } else { - errno = ETIMEDOUT; + close(fd); + return FBTunnelProbeError(waitResult, + [NSString stringWithFormat:@"Cannot reach the SOCKS5 proxy at %@:%u", + proxyIP, port], + outRetryable); } } if (0 != connected) { int err = errno; close(fd); - *outUnreachable = YES; + *outRetryable = YES; return FBTunnelError([NSString stringWithFormat: @"Cannot reach the SOCKS5 proxy at %@:%u: %s", proxyIP, port, strerror(err)]); } - fcntl(fd, F_SETFL, flags); // Mirror hev_socks5_client_write_auth_methods exactly: it always offers a single method - // username/password when BOTH fields are set, no-auth otherwise. Offering both here would let @@ -192,15 +359,23 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia greeting[1] = 1; greeting[2] = hasCredentials ? 0x02 : 0x00; const size_t greetingLength = sizeof(greeting); - if (send(fd, greeting, greetingLength, 0) != (ssize_t)greetingLength) { + FBTunnelProbeIOResult ioResult = FBTunnelWriteFully(fd, greeting, greetingLength, + candidateDeadline, startupDeadline, + startupFence); + if (FBTunnelProbeIOResultSuccess != ioResult) { close(fd); - return FBTunnelError(@"The SOCKS5 proxy closed the connection during the greeting"); + return FBTunnelProbeError(ioResult, + @"The SOCKS5 proxy closed the connection during the greeting", + outRetryable); } uint8_t choice[2] = {0}; - if (!FBTunnelReadFully(fd, choice, sizeof(choice))) { + ioResult = FBTunnelReadFully(fd, choice, sizeof(choice), candidateDeadline, startupDeadline, + startupFence); + if (FBTunnelProbeIOResultSuccess != ioResult) { close(fd); - return FBTunnelError(@"The SOCKS5 proxy did not answer the greeting"); + return FBTunnelProbeError(ioResult, @"The SOCKS5 proxy did not answer the greeting", + outRetryable); } if (0x05 != choice[0]) { close(fd); @@ -213,6 +388,14 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia ? @"The SOCKS5 proxy rejected username/password authentication" : @"The SOCKS5 proxy requires authentication, but the URI has no user AND password pair"); } + if (!FBSocks5TunnelAuthenticationMethodWasOffered(choice[1], hasCredentials)) { + close(fd); + if (0x02 == choice[1]) { + return FBTunnelError(@"The SOCKS5 proxy selected username/password authentication that the client did not offer"); + } + return FBTunnelError([NSString stringWithFormat: + @"The SOCKS5 proxy selected unsupported authentication method 0x%02x", choice[1]]); + } if (0x02 == choice[1]) { NSData *userData = [user dataUsingEncoding:NSUTF8StringEncoding]; NSData *passData = [(NSString *)pass dataUsingEncoding:NSUTF8StringEncoding]; @@ -227,54 +410,115 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia uint8_t passLength = (uint8_t)passData.length; [auth appendBytes:&passLength length:1]; [auth appendData:passData]; - if (send(fd, auth.bytes, auth.length, 0) != (ssize_t)auth.length) { + ioResult = FBTunnelWriteFully(fd, auth.bytes, auth.length, candidateDeadline, startupDeadline, + startupFence); + if (FBTunnelProbeIOResultSuccess != ioResult) { close(fd); - return FBTunnelError(@"The SOCKS5 proxy closed the connection during authentication"); + return FBTunnelProbeError(ioResult, + @"The SOCKS5 proxy closed the connection during authentication", + outRetryable); } uint8_t authReply[2] = {0}; - if (!FBTunnelReadFully(fd, authReply, sizeof(authReply))) { + ioResult = FBTunnelReadFully(fd, authReply, sizeof(authReply), candidateDeadline, + startupDeadline, startupFence); + if (FBTunnelProbeIOResultSuccess != ioResult) { close(fd); - return FBTunnelError(@"The SOCKS5 proxy did not answer the authentication request"); + return FBTunnelProbeError(ioResult, + @"The SOCKS5 proxy did not answer the authentication request", + outRetryable); } - if (0x00 != authReply[1]) { + if (!FBSocks5TunnelUsernamePasswordAuthReplySucceeded(authReply[0], authReply[1])) { close(fd); + if (0x01 != authReply[0]) { + return FBTunnelError(@"The SOCKS5 proxy returned an invalid username/password authentication reply"); + } return FBTunnelError(@"The SOCKS5 proxy rejected the configured credentials"); } - } else if (0x00 != choice[1]) { - close(fd); - return FBTunnelError([NSString stringWithFormat: - @"The SOCKS5 proxy selected unsupported authentication method 0x%02x", choice[1]]); } close(fd); return nil; } @interface FBTunnelPacketProvider () -@property (nonatomic, nullable) FBTunnelHevRunner *runner; -/** Set once a stop was requested, so the engine's exit is not mistaken for a crash. */ -@property (atomic) BOOL isStopping; +@property (atomic, nullable) FBTunnelHevRunner *runner; +@property (nonatomic, strong) FBSocks5TunnelStartupFence *startupFence; @end @implementation FBTunnelPacketProvider +- (FBSocks5TunnelStartupFence *)startupFence +{ + @synchronized (self) { + if (nil == _startupFence) { + _startupFence = [[FBSocks5TunnelStartupFence alloc] init]; + } + return _startupFence; + } +} + +- (void)finishStartupWithError:(nullable NSError *)error +{ + NSError *stoppedError = FBTunnelError(@"The tunnel was stopped while it was still starting up"); + if ([self.startupFence finishStartupWithError:error stoppedError:stoppedError]) { + [self finishStopCleanup]; + } +} + +- (void)finishStopCleanup +{ + FBTunnelHevRunner *runner; + @synchronized (self) { + runner = self.runner; + self.runner = nil; + } + + [runner stopAndWait:5.0]; + FBSocks5TunnelStartupFence *startupFence = self.startupFence; + [self setTunnelNetworkSettings:nil completionHandler:^(NSError *_Nullable settingsError) { + if (nil != settingsError) { + NSLog(@"WebDriverAgentTunnel: failed to clear tunnel network settings during stop: %@", + settingsError.localizedDescription); + } + [startupFence finishStopCleanup]; + }]; +} + - (void)startTunnelWithOptions:(nullable NSDictionary *)options completionHandler:(void (^)(NSError *_Nullable))completionHandler { + FBSocks5TunnelStartupFence *startupFence = self.startupFence; + [startupFence beginStartupWithCompletion:completionHandler]; + NSDate *startupDeadline = FBSocks5TunnelStartupDeadlineFromOptions(options, [NSDate date]); NETunnelProviderProtocol *protocol = (NETunnelProviderProtocol *)self.protocolConfiguration; NSDictionary *config = [protocol isKindOfClass:NETunnelProviderProtocol.class] ? protocol.providerConfiguration : nil; NSString *host = config[FBSocks5KeyHost]; NSNumber *port = config[FBSocks5KeyPort]; + BOOL controlIsIPv6 = NO; + NSString *controlAddress = FBTunnelNormalizedIPAddress(config[FBSocks5KeyControlAddress], &controlIsIPv6); BOOL remoteDNS = [config[FBSocks5KeyRemoteDNS] boolValue]; if (0 == host.length || nil == port) { - completionHandler(FBTunnelError(@"The tunnel provider configuration is missing the proxy host/port")); + [self finishStartupWithError:FBTunnelError(@"The tunnel provider configuration is missing the proxy host/port")]; return; } - NSArray *candidates = FBTunnelResolveHostAddresses(host); + BOOL resolutionCompleted = NO; + NSArray *candidates = FBTunnelResolveHostAddresses(host, startupDeadline, + startupFence, &resolutionCompleted); + if (!resolutionCompleted) { + NSError *resolutionError = startupFence.isStopping + ? FBTunnelError(@"The tunnel was stopped while it was still starting up") + : FBTunnelError(@"Timed out resolving the SOCKS5 proxy host"); + [self finishStartupWithError:resolutionError]; + return; + } if (0 == candidates.count) { - completionHandler(FBTunnelError([NSString stringWithFormat:@"Cannot resolve the SOCKS5 proxy host '%@'", host])); + [self finishStartupWithError:FBTunnelError([NSString stringWithFormat:@"Cannot resolve the SOCKS5 proxy host '%@'", host])]; + return; + } + if (FBSocks5TunnelRemainingStartupTime(startupDeadline, [NSDate date], FBTunnelProbeTimeout) <= 0) { + [self finishStartupWithError:FBTunnelError(@"Timed out while starting the SOCKS5 tunnel")]; return; } NSLog(@"WebDriverAgentTunnel: starting tunnel through %@:%@ (resolved %@, remoteDNS=%d)", @@ -282,17 +526,31 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) // Fail before any routes are installed, so an unreachable proxy or bad credentials surface as // a start error instead of a "connected" tunnel that drops every packet. Probing the resolved - // addresses in order gives the usual DNS failover: an unreachable endpoint moves on to the - // next record, while a protocol/credential rejection is answered by the proxy itself and - // fails immediately (it would repeat on every address of the same server). + // addresses in order gives the usual DNS failover: a connect/timeout/EOF transport failure + // moves on to the next record, while a protocol/credential rejection is answered by the proxy + // itself and fails immediately (it would repeat on every address of the same service). NSString *proxyIP = nil; BOOL proxyIsIPv6 = NO; NSError *probeError = nil; for (NSString *candidate in candidates) { + if (self.startupFence.isStopping) { + probeError = FBTunnelError(@"The tunnel was stopped while it was still starting up"); + break; + } + NSDate *now = [NSDate date]; + NSTimeInterval candidateBudget = FBSocks5TunnelRemainingStartupTime(startupDeadline, now, + FBTunnelProbeTimeout); + if (candidateBudget <= 0) { + probeError = FBTunnelError(@"Timed out while starting the SOCKS5 tunnel"); + break; + } + NSDate *candidateDeadline = [now dateByAddingTimeInterval:candidateBudget]; BOOL candidateIsIPv6 = [candidate containsString:@":"]; - BOOL unreachable = NO; + BOOL retryable = NO; probeError = FBTunnelProbeSocks5(candidate, candidateIsIPv6, (uint16_t)port.unsignedIntValue, - config[FBSocks5KeyUser], config[FBSocks5KeyPass], &unreachable); + config[FBSocks5KeyUser], config[FBSocks5KeyPass], + candidateDeadline, startupDeadline, self.startupFence, + &retryable); if (nil == probeError) { proxyIP = candidate; proxyIsIPv6 = candidateIsIPv6; @@ -300,24 +558,40 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) } NSLog(@"WebDriverAgentTunnel: proxy pre-flight failed for %@: %@", candidate, probeError.localizedDescription); - if (!unreachable) { + if (!retryable) { break; } } if (nil == proxyIP) { - completionHandler(probeError); + [self finishStartupWithError:probeError]; return; } + if (self.startupFence.isStopping) { + [self finishStartupWithError:FBTunnelError(@"The tunnel was stopped while it was still starting up")]; + return; + } + + if (nil != controlAddress) { + NSLog(@"WebDriverAgentTunnel: preserving WDA control route to %@", controlAddress); + } NEPacketTunnelNetworkSettings *settings = [[NEPacketTunnelNetworkSettings alloc] initWithTunnelRemoteAddress:proxyIP]; NEIPv4Settings *ipv4 = [[NEIPv4Settings alloc] initWithAddresses:@[FBSocks5TunnelIPv4Address] subnetMasks:@[FBSocks5TunnelIPv4Netmask]]; ipv4.includedRoutes = @[NEIPv4Route.defaultRoute]; + NSMutableArray *excludedIPv4Routes = [NSMutableArray array]; if (!proxyIsIPv6) { // The engine's own TCP connection to the proxy must not loop back into the tunnel. - ipv4.excludedRoutes = @[[[NEIPv4Route alloc] initWithDestinationAddress:proxyIP - subnetMask:@"255.255.255.255"]]; + [excludedIPv4Routes addObject:[[NEIPv4Route alloc] initWithDestinationAddress:proxyIP + subnetMask:@"255.255.255.255"]]; + } + if (nil != controlAddress && !controlIsIPv6 && ![controlAddress isEqualToString:proxyIP]) { + [excludedIPv4Routes addObject:[[NEIPv4Route alloc] initWithDestinationAddress:controlAddress + subnetMask:@"255.255.255.255"]]; + } + if (excludedIPv4Routes.count > 0) { + ipv4.excludedRoutes = excludedIPv4Routes.copy; } settings.IPv4Settings = ipv4; @@ -331,10 +605,18 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) NEIPv6Settings *ipv6 = [[NEIPv6Settings alloc] initWithAddresses:@[FBSocks5TunnelIPv6Address] networkPrefixLengths:@[@(FBSocks5TunnelIPv6PrefixLength)]]; ipv6.includedRoutes = @[NEIPv6Route.defaultRoute]; + NSMutableArray *excludedIPv6Routes = [NSMutableArray array]; if (proxyIsIPv6) { // Same reasoning as the IPv4 exclusion: keep the engine's own dial out of its own tunnel. - ipv6.excludedRoutes = @[[[NEIPv6Route alloc] initWithDestinationAddress:proxyIP - networkPrefixLength:@128]]; + [excludedIPv6Routes addObject:[[NEIPv6Route alloc] initWithDestinationAddress:proxyIP + networkPrefixLength:@128]]; + } + if (nil != controlAddress && controlIsIPv6 && ![controlAddress isEqualToString:proxyIP]) { + [excludedIPv6Routes addObject:[[NEIPv6Route alloc] initWithDestinationAddress:controlAddress + networkPrefixLength:@128]]; + } + if (excludedIPv6Routes.count > 0) { + ipv6.excludedRoutes = excludedIPv6Routes.copy; } settings.IPv6Settings = ipv6; // socks5h: point DNS at hev's mapdns so queries become hostname-preserving CONNECTs. @@ -352,16 +634,24 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) [self setTunnelNetworkSettings:settings completionHandler:^(NSError *_Nullable settingsError) { __strong typeof(weakSelf) strongSelf = weakSelf; if (nil == strongSelf) { - completionHandler(FBTunnelError(@"The tunnel provider was deallocated during startup")); + NSError *deallocatedError = FBTunnelError(@"The tunnel provider was deallocated during startup"); + NSError *stoppedError = FBTunnelError(@"The tunnel was stopped while it was still starting up"); + if ([startupFence finishStartupWithError:deallocatedError stoppedError:stoppedError]) { + [startupFence finishStopCleanup]; + } return; } if (nil != settingsError) { - completionHandler(settingsError); + [strongSelf finishStartupWithError:settingsError]; + return; + } + if (strongSelf.startupFence.isStopping) { + [strongSelf finishStartupWithError:FBTunnelError(@"The tunnel was stopped while it was still starting up")]; return; } int tunFd = FBTunnelFindTunFd(); if (tunFd < 0) { - completionHandler(FBTunnelError(@"Cannot locate the utun file descriptor in the tunnel provider")); + [strongSelf finishStartupWithError:FBTunnelError(@"Cannot locate the utun file descriptor in the tunnel provider")]; return; } // The engine must connect to the already probed address (see FBTunnelResolveHostAddresses). @@ -390,30 +680,24 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) shouldCancel = startupAcknowledged; } dispatch_semaphore_signal(settled); - if (!shouldCancel || nil == exitSelf || exitSelf.isStopping) { + if (!shouldCancel || nil == exitSelf || exitSelf.startupFence.isStopping) { return; } NSLog(@"WebDriverAgentTunnel: engine exited unexpectedly (code %d); cancelling the tunnel", exitCode); [exitSelf cancelTunnelWithError: FBTunnelError([NSString stringWithFormat:@"The SOCKS5 engine exited unexpectedly with code %d", exitCode])]; }; - // Publishing and starting the runner must be atomic against stopTunnelWithReason:. A stop - // that arrives while the probe or setTunnelNetworkSettings is still pending finds no runner - // to stop and completes; without this section the continuation would then start a late - // engine inside a provider the system already considers torn down. Under the lock exactly - // one path wins: either the stop already recorded isStopping and startup aborts before the - // engine exists, or the runner is published and started first and the stop finds it. - BOOL stoppedDuringStartup = NO; - @synchronized (strongSelf) { - if (strongSelf.isStopping) { - stoppedDuringStartup = YES; - } else { + // Publishing and starting the runner must be atomic against stopTunnelWithReason:. The + // startup fence either rejects this action after a stop request, or keeps stop cleanup + // deferred until startup hands ownership of the published runner back to the fence. + BOOL started = [strongSelf.startupFence performStartupActionIfNotStopping:^{ + @synchronized (strongSelf) { strongSelf.runner = runner; [runner startWithConfigYAML:yaml tunFd:tunFd exitHandler:exitHandler]; } - } - if (stoppedDuringStartup) { - completionHandler(FBTunnelError(@"The tunnel was stopped while it was still starting up")); + }]; + if (!started) { + [strongSelf finishStartupWithError:FBTunnelError(@"The tunnel was stopped while it was still starting up")]; return; } dispatch_semaphore_wait(settled, dispatch_time(DISPATCH_TIME_NOW, @@ -427,11 +711,15 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) } } if (startupFailed) { - strongSelf.runner = nil; - completionHandler(FBTunnelError(@"The SOCKS5 engine failed to start; check the proxy configuration")); + @synchronized (strongSelf) { + if (strongSelf.runner == runner) { + strongSelf.runner = nil; + } + } + [strongSelf finishStartupWithError:FBTunnelError(@"The SOCKS5 engine failed to start; check the proxy configuration")]; return; } - completionHandler(nil); + [strongSelf finishStartupWithError:nil]; }]; } @@ -439,17 +727,9 @@ - (void)stopTunnelWithReason:(NEProviderStopReason)reason completionHandler:(void (^)(void))completionHandler { NSLog(@"WebDriverAgentTunnel: stopping tunnel (reason %ld)", (long)reason); - FBTunnelHevRunner *runner; - // Atomic against the startup continuation (see startTunnelWithOptions:): after this section - // a startup that has not yet published its runner is guaranteed to observe isStopping and - // abort instead of starting a late engine. - @synchronized (self) { - self.isStopping = YES; - runner = self.runner; - self.runner = nil; + if ([self.startupFence requestStopWithCompletion:completionHandler]) { + [self finishStopCleanup]; } - [runner stopAndWait:5.0]; - completionHandler(); } - (void)handleAppMessage:(NSData *)messageData diff --git a/docs/socks5-tunnel.md b/docs/socks5-tunnel.md index a2d5be3a85..a55b7f9374 100644 --- a/docs/socks5-tunnel.md +++ b/docs/socks5-tunnel.md @@ -43,12 +43,17 @@ Network Extension capability. the SOCKS5 CONNECT. With plain `socks5` the tunnel uses public resolvers (8.8.8.8/1.1.1.1) and relays the DNS datagrams through the proxy's UDP relay — the proxy must support UDP ASSOCIATE for that; prefer `socks5h` when unsure. -- `timeout` (optional, default 30) — seconds for the whole connect flow (consent + tunnel - reaching connected). +- `timeout` (optional, default 30, maximum 300) — seconds for the whole connect flow + (consent + tunnel reaching connected). - `consentButtonLabels` (optional, default `["Allow"]`) — labels to look for on the system "Would Like to Add VPN Configurations" alert. Pass the localized label when the device language is not English. +The tunnel installs full IPv4 and IPv6 routes, but excludes both the selected proxy address +and the IP address of the HTTP client that issued `connect`. The latter preserves the active +WDA response and subsequent `stats` or `disconnect` requests when the controller reaches the +device through its physical default route. + `stats` response value (also returned by successful `connect`/`disconnect` calls): ```json @@ -89,9 +94,10 @@ a no-op while the stamp file matches the submodule SHA (`--force` rebuilds). Pla Like the broadcast extension, the appex cannot reach the Xcode-generated `Runner.app` through a regular embed phase; the tunnel schemes' post-action `Scripts/embed-tunnel-extension.sh` -copies it into `Runner.app/PlugIns`, rewrites its bundle id to `.tunnel` and -re-signs inner-first (post-actions run on scheme-based CLI builds, including -`build-for-testing`). +copies it into `Runner.app/PlugIns`, verifies its build-time bundle id is `.tunnel`, +and re-signs inner-first (post-actions run on scheme-based CLI builds, including +`build-for-testing`). Set `WDA_PRODUCT_BUNDLE_IDENTIFIER` to the runner's base identifier so +the runner and extension are provisioned with their final identifiers. ## Signing (device) diff --git a/lib/xcodebuild.ts b/lib/xcodebuild.ts index 34753beca4..5081823457 100644 --- a/lib/xcodebuild.ts +++ b/lib/xcodebuild.ts @@ -418,7 +418,7 @@ export class XcodeBuild { args.push(`DEVELOPMENT_TEAM=${this.xcodeOrgId}`, `CODE_SIGN_IDENTITY=${this.xcodeSigningId}`); } if (this.updatedWDABundleId) { - args.push(`PRODUCT_BUNDLE_IDENTIFIER=${this.updatedWDABundleId}`); + args.push(`WDA_PRODUCT_BUNDLE_IDENTIFIER=${this.updatedWDABundleId}`); } } diff --git a/test/unit/webdriveragent.spec.ts b/test/unit/webdriveragent.spec.ts index c5273f1a1e..371f5b2209 100644 --- a/test/unit/webdriveragent.spec.ts +++ b/test/unit/webdriveragent.spec.ts @@ -120,6 +120,18 @@ describe('WebDriverAgent', function () { }), ); }); + + it('should pass a custom WDA bundle id without overriding every target bundle id', function () { + const agent = new WebDriverAgent({ + ...fakeConstructorArgs, + realDevice: true, + updatedWDABundleId: 'io.appium.wda', + }); + const {args} = (agent.xcodebuild as any).getCommand(true); + + assert.ok(args.includes('WDA_PRODUCT_BUNDLE_IDENTIFIER=io.appium.wda')); + assert.ok(!args.includes('PRODUCT_BUNDLE_IDENTIFIER=io.appium.wda')); + }); }); describe('launch', function () { From 93d0fbdc2d6969e5ed0ad05ea8fe71948a992e75 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:23:51 +0200 Subject: [PATCH 21/23] chore: update SOCKS5 copyrights --- WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h | 2 +- WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m | 2 +- WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h | 2 +- WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m | 2 +- WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h | 2 +- WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m | 2 +- WebDriverAgentLib/Utilities/FBSocks5URI.h | 2 +- WebDriverAgentLib/Utilities/FBSocks5URI.m | 2 +- .../IntegrationTests/FBMobilerunSocks5IntegrationTests.m | 2 +- WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m | 2 +- WebDriverAgentTests/UnitTests/FBSocks5URITests.m | 2 +- WebDriverAgentTunnel/FBTunFdFinder.h | 2 +- WebDriverAgentTunnel/FBTunFdFinder.m | 2 +- WebDriverAgentTunnel/FBTunnelHevRunner.h | 2 +- WebDriverAgentTunnel/FBTunnelHevRunner.m | 2 +- WebDriverAgentTunnel/FBTunnelPacketProvider.h | 2 +- WebDriverAgentTunnel/FBTunnelPacketProvider.m | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h index 1bbba7b137..afb90f9178 100644 --- a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m index be57b46b09..9e7971d084 100644 --- a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h index f40e6ca9d5..95eb884dae 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 04ee72062b..83d0ae93b2 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h index 4ded060842..8c98e15494 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m index a16f8dc6a8..da2299e2d6 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.h b/WebDriverAgentLib/Utilities/FBSocks5URI.h index d063fa17cd..5471c296f4 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5URI.h +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.h @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.m b/WebDriverAgentLib/Utilities/FBSocks5URI.m index f18b2db67e..6cd8ba8ef6 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5URI.m +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m b/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m index af10303d67..af37068abd 100644 --- a/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m index c8ec542133..9a5cf04a4c 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m index 1fe65a1532..138fd4ccc4 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTunnel/FBTunFdFinder.h b/WebDriverAgentTunnel/FBTunFdFinder.h index 835bdf1b94..41c19595c9 100644 --- a/WebDriverAgentTunnel/FBTunFdFinder.h +++ b/WebDriverAgentTunnel/FBTunFdFinder.h @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTunnel/FBTunFdFinder.m b/WebDriverAgentTunnel/FBTunFdFinder.m index 72ff129e76..34233bb6f0 100644 --- a/WebDriverAgentTunnel/FBTunFdFinder.m +++ b/WebDriverAgentTunnel/FBTunFdFinder.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.h b/WebDriverAgentTunnel/FBTunnelHevRunner.h index 8c98732230..d8c56f28ee 100644 --- a/WebDriverAgentTunnel/FBTunnelHevRunner.h +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.h @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.m b/WebDriverAgentTunnel/FBTunnelHevRunner.m index 21565d3997..279a403c38 100644 --- a/WebDriverAgentTunnel/FBTunnelHevRunner.m +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.h b/WebDriverAgentTunnel/FBTunnelPacketProvider.h index 7c8c7c89e3..07bb1acfa6 100644 --- a/WebDriverAgentTunnel/FBTunnelPacketProvider.h +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.h @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m index 4c9c777ace..7037773faf 100644 --- a/WebDriverAgentTunnel/FBTunnelPacketProvider.m +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. + * Copyright (c) 2026-present, Droidrun. * All rights reserved. * * This source code is licensed under the BSD-style license found in the From 670f34f67b1bf93325b83c2c5b797cf32b68defc Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:59:40 +0200 Subject: [PATCH 22/23] fix: handle stale VPN saves and credential limits --- .../Utilities/FBSocks5TunnelManager.m | 196 +++++++++++++----- WebDriverAgentLib/Utilities/FBSocks5URI.m | 5 + .../UnitTests/FBSocks5ConfigTests.m | 49 ++++- .../UnitTests/FBSocks5URITests.m | 20 ++ 4 files changed, 212 insertions(+), 58 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m index 83d0ae93b2..a6aa7a7d6e 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -8,6 +8,7 @@ #import "FBSocks5TunnelManager.h" +#include #import #import "FBSocks5TunnelProtocol.h" @@ -38,10 +39,46 @@ static BOOL FBSocks5TunnelExtensionEmbedded(NSBundle *bundle) } static const NSTimeInterval FBSocks5PreferencesTimeout = 10.0; +static NSString *const FBSocks5NEVPNErrorDomain = @"NEVPNErrorDomain"; +static NSString *const FBSocks5NEConfigurationErrorDomain = @"NEConfigurationErrorDomain"; +static const NSInteger FBSocks5NEVPNErrorConfigurationStale = 4; +static const NSInteger FBSocks5NEConfigurationErrorPermissionDenied = 10; + +typedef NS_ENUM(NSInteger, FBSocks5TunnelManagerSaveDisposition) { + FBSocks5TunnelManagerSaveDispositionRetryStale, + FBSocks5TunnelManagerSaveDispositionNotAuthorized, + FBSocks5TunnelManagerSaveDispositionInternal, +}; + +static BOOL FBSocks5TunnelManagerErrorChainContainsPermissionFailure(NSError *error) +{ + NSError *candidate = error; + for (NSUInteger depth = 0; nil != candidate && depth < 8; depth++) { + if (([candidate.domain isEqualToString:FBSocks5NEConfigurationErrorDomain] + && FBSocks5NEConfigurationErrorPermissionDenied == candidate.code) + || ([candidate.domain isEqualToString:NSPOSIXErrorDomain] + && (EACCES == candidate.code || EPERM == candidate.code)) + || ([candidate.domain isEqualToString:NSCocoaErrorDomain] + && (NSFileReadNoPermissionError == candidate.code + || NSFileWriteNoPermissionError == candidate.code))) { + return YES; + } + id underlying = candidate.userInfo[NSUnderlyingErrorKey]; + candidate = [underlying isKindOfClass:NSError.class] ? underlying : nil; + } + return NO; +} -FBSocks5TunnelManagerError FBSocks5TunnelManagerSaveFailureCode(BOOL completed) +FBSocks5TunnelManagerSaveDisposition FBSocks5TunnelManagerSaveDispositionForError(NSError *error) { - return completed ? FBSocks5TunnelManagerErrorNotAuthorized : FBSocks5TunnelManagerErrorTimeout; + if ([error.domain isEqualToString:FBSocks5NEVPNErrorDomain] + && FBSocks5NEVPNErrorConfigurationStale == error.code) { + return FBSocks5TunnelManagerSaveDispositionRetryStale; + } + if (FBSocks5TunnelManagerErrorChainContainsPermissionFailure(error)) { + return FBSocks5TunnelManagerSaveDispositionNotAuthorized; + } + return FBSocks5TunnelManagerSaveDispositionInternal; } /** @@ -247,6 +284,10 @@ @interface FBSocks5TunnelManager () @property (nonatomic, nullable) NETunnelProviderManager *activeManager; /** Monotonic ms timestamp of the last consent tap dispatch; guards the re-attempt cooldown. */ @property (nonatomic) uint64_t lastConsentTapMs; +- (BOOL)reloadManager:(NETunnelProviderManager *)manager + deadline:(NSDate *)deadline + context:(NSString *)context + error:(NSError **)error; @end @implementation FBSocks5TunnelManager @@ -319,6 +360,37 @@ - (BOOL)loadAllManagers:(NSArray **)outManagers return YES; } +- (BOOL)reloadManager:(NETunnelProviderManager *)manager + deadline:(NSDate *)deadline + context:(NSString *)context + error:(NSError **)error +{ + NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout); + if (budget <= 0) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + [NSString stringWithFormat:@"Timed out %@", context]); + } + __block volatile atomic_bool reloadDone = false; + __block NSError *reloadError = nil; + [manager loadFromPreferencesWithCompletionHandler:^(NSError *err) { + reloadError = err; + atomic_store_explicit(&reloadDone, true, memory_order_release); + }]; + [[[[FBRunLoopSpinner new] timeout:budget] interval:0.05] spinUntilTrue:^BOOL{ + return atomic_load_explicit(&reloadDone, memory_order_acquire); + }]; + if (!atomic_load_explicit(&reloadDone, memory_order_acquire)) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + [NSString stringWithFormat:@"Timed out %@", context]); + } + if (nil != reloadError) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, + [NSString stringWithFormat:@"Cannot %@: %@", + context, reloadError.localizedDescription]); + } + return YES; +} + // Returns the manager for our own provider, preferring one that is not idle. Duplicates with the // same provider id can exist transiently (see the pending-save fence); picking whichever came first // would let a disconnected duplicate shadow the running tunnel, so disconnect would report @@ -564,64 +636,80 @@ - (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri manager.localizedDescription = FBSocks5TunnelDescription; manager.enabled = YES; - __block volatile atomic_bool saveDone = false; - __block NSError *saveError = nil; - dispatch_semaphore_t saveSignal = dispatch_semaphore_create(0); - self.lifecycle.pendingSaveSignal = saveSignal; - [manager saveToPreferencesWithCompletionHandler:^(NSError *err) { - saveError = err; - atomic_store_explicit(&saveDone, true, memory_order_release); - dispatch_semaphore_signal(saveSignal); - }]; - // Keep re-attempting for as long as the alert is still up: a dispatched tap can be shed by the - // system, so 'we dispatched one' is not evidence that it landed. tapConsentButtonWithLabels: - // paces the re-attempts itself and answers NO once the alert is gone. __block BOOL consentTapped = NO; - // No MAX(..., 1.0) floor here: granting an already-exhausted request another second is - // exactly the overshoot the caller's timeout is supposed to prevent. - [[[[FBRunLoopSpinner new] timeout:deadline.timeIntervalSinceNow] interval:0.3] spinUntilTrue:^BOOL{ - if (atomic_load_explicit(&saveDone, memory_order_acquire)) { - return YES; + NSUInteger staleRetries = 0; + while (YES) { + if (deadline.timeIntervalSinceNow <= 0) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out before saving the VPN configuration"); } - if ([self tapConsentButtonWithLabels:labels deadline:deadline]) { - consentTapped = YES; + __block volatile atomic_bool saveDone = false; + __block NSError *saveError = nil; + dispatch_semaphore_t saveSignal = dispatch_semaphore_create(0); + self.lifecycle.pendingSaveSignal = saveSignal; + [manager saveToPreferencesWithCompletionHandler:^(NSError *err) { + saveError = err; + atomic_store_explicit(&saveDone, true, memory_order_release); + dispatch_semaphore_signal(saveSignal); + }]; + // Keep re-attempting for as long as the alert is still up: a dispatched tap can be shed by the + // system, so 'we dispatched one' is not evidence that it landed. tapConsentButtonWithLabels: + // paces the re-attempts itself and answers NO once the alert is gone. + // No MAX(..., 1.0) floor here: granting an already-exhausted request another second is + // exactly the overshoot the caller's timeout is supposed to prevent. + [[[[FBRunLoopSpinner new] timeout:deadline.timeIntervalSinceNow] interval:0.3] spinUntilTrue:^BOOL{ + if (atomic_load_explicit(&saveDone, memory_order_acquire)) { + return YES; + } + if ([self tapConsentButtonWithLabels:labels deadline:deadline]) { + consentTapped = YES; + } + return NO; + }]; + if (!atomic_load_explicit(&saveDone, memory_order_acquire)) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + [NSString stringWithFormat: + @"Timed out saving the VPN configuration. The consent alert was %@; " + "pass 'consentButtonLabels' if the device language is not English, and note that " + "devices with a passcode cannot confirm the VPN consent automatically", + consentTapped ? @"confirmed" : @"not confirmed"]); } - return NO; - }]; - if (!atomic_load_explicit(&saveDone, memory_order_acquire)) { - return FBSocks5Fail(error, FBSocks5TunnelManagerSaveFailureCode(NO), - [NSString stringWithFormat: - @"Timed out saving the VPN configuration. The consent alert was %@; " - "pass 'consentButtonLabels' if the device language is not English, and note that " - "devices with a passcode cannot confirm the VPN consent automatically", - consentTapped ? @"confirmed" : @"not confirmed"]); - } - self.lifecycle.pendingSaveSignal = nil; - if (nil != saveError) { - return FBSocks5Fail(error, FBSocks5TunnelManagerSaveFailureCode(YES), - [NSString stringWithFormat:@"The VPN configuration was not authorized: %@", - saveError.localizedDescription]); + self.lifecycle.pendingSaveSignal = nil; + if (nil == saveError) { + break; + } + FBSocks5TunnelManagerSaveDisposition disposition = + FBSocks5TunnelManagerSaveDispositionForError(saveError); + if (FBSocks5TunnelManagerSaveDispositionRetryStale == disposition && 0 == staleRetries) { + staleRetries++; + [FBLogger log:@"socks5/connect: VPN configuration became stale; reloading and retrying the save once"]; + if (![self reloadManager:manager + deadline:deadline + context:@"reloading the stale VPN configuration" + error:error]) { + return NO; + } + manager.protocolConfiguration = protocol; + manager.localizedDescription = FBSocks5TunnelDescription; + manager.enabled = YES; + continue; + } + FBSocks5TunnelManagerError code = FBSocks5TunnelManagerSaveDispositionNotAuthorized == disposition + ? FBSocks5TunnelManagerErrorNotAuthorized + : FBSocks5TunnelManagerErrorInternal; + NSString *prefix = FBSocks5TunnelManagerSaveDispositionNotAuthorized == disposition + ? @"The VPN configuration was not authorized" + : @"Cannot save the VPN configuration"; + return FBSocks5Fail(error, code, + [NSString stringWithFormat:@"%@: %@", prefix, saveError.localizedDescription]); } // A freshly saved configuration must be re-loaded before the tunnel can be started. - __block volatile atomic_bool reloadDone = false; - __block NSError *reloadError = nil; - [manager loadFromPreferencesWithCompletionHandler:^(NSError *err) { - reloadError = err; - atomic_store_explicit(&reloadDone, true, memory_order_release); - }]; - [[[[FBRunLoopSpinner new] timeout:FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout)] interval:0.05] spinUntilTrue:^BOOL{ - return atomic_load_explicit(&reloadDone, memory_order_acquire); - }]; - // Same distinction as loadAllManagers:deadline:error: above. - if (!atomic_load_explicit(&reloadDone, memory_order_acquire)) { - return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, - @"Timed out reloading the saved VPN configuration"); - } - if (nil != reloadError) { - return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, - [NSString stringWithFormat:@"Cannot reload the saved VPN configuration: %@", - reloadError.localizedDescription]); + if (![self reloadManager:manager + deadline:deadline + context:@"reloading the saved VPN configuration" + error:error]) { + return NO; } if (deadline.timeIntervalSinceNow <= 0) { diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.m b/WebDriverAgentLib/Utilities/FBSocks5URI.m index 6cd8ba8ef6..d3f35dae11 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5URI.m +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.m @@ -105,6 +105,11 @@ + (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **) FBSocks5URISetError(error, @"The socks5 URI must include both a username and password, or neither"); return nil; } + if ([user lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > UINT8_MAX + || [pass lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > UINT8_MAX) { + FBSocks5URISetError(error, @"The socks5 username and password must each be at most 255 UTF-8 bytes"); + return nil; + } FBSocks5URI *uri = [[self alloc] init]; uri.host = host; diff --git a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m index 9a5cf04a4c..c2576764d0 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m @@ -7,6 +7,7 @@ */ #import +#import #import "FBSocks5TunnelManager.h" #import "FBSocks5TunnelProtocol.h" @@ -19,7 +20,13 @@ - (BOOL)performLockedWithDeadline:(nullable NSDate *)deadline - (BOOL)fencePendingSaveWithDeadline:(nullable NSDate *)deadline error:(NSError **)error; @end -extern FBSocks5TunnelManagerError FBSocks5TunnelManagerSaveFailureCode(BOOL completed); +typedef NS_ENUM(NSInteger, FBSocks5TunnelManagerSaveDisposition) { + FBSocks5TunnelManagerSaveDispositionRetryStale, + FBSocks5TunnelManagerSaveDispositionNotAuthorized, + FBSocks5TunnelManagerSaveDispositionInternal, +}; + +extern FBSocks5TunnelManagerSaveDisposition FBSocks5TunnelManagerSaveDispositionForError(NSError *error); extern NSDictionary *_Nullable FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(NSBundle *bundle); @interface FBSocks5ConfigTests : XCTestCase @@ -328,10 +335,44 @@ - (void)testProxyMayOnlySelectAnAuthenticationMethodOfferedByTheClient XCTAssertFalse(FBSocks5TunnelAuthenticationMethodWasOffered(0x01, YES)); } -- (void)testUnfinishedPreferencesSaveIsClassifiedAsTimeout +- (void)testStalePreferencesSaveRequiresReloadAndRetry +{ + NSError *stale = [NSError errorWithDomain:NEVPNErrorDomain + code:NEVPNErrorConfigurationStale + userInfo:nil]; + NSError *sameCodeWrongDomain = [NSError errorWithDomain:@"test" + code:NEVPNErrorConfigurationStale + userInfo:nil]; + + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(stale), + FBSocks5TunnelManagerSaveDispositionRetryStale); + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(sameCodeWrongDomain), + FBSocks5TunnelManagerSaveDispositionInternal); +} + +- (void)testOnlyPermissionFailuresAreClassifiedAsNotAuthorized { - XCTAssertEqual(FBSocks5TunnelManagerSaveFailureCode(NO), FBSocks5TunnelManagerErrorTimeout); - XCTAssertEqual(FBSocks5TunnelManagerSaveFailureCode(YES), FBSocks5TunnelManagerErrorNotAuthorized); + NSError *permission = [NSError errorWithDomain:@"NEConfigurationErrorDomain" + code:10 + userInfo:nil]; + NSError *wrappedPermission = [NSError errorWithDomain:NEVPNErrorDomain + code:NEVPNErrorConfigurationReadWriteFailed + userInfo:@{NSUnderlyingErrorKey: permission}]; + NSError *readWrite = [NSError errorWithDomain:NEVPNErrorDomain + code:NEVPNErrorConfigurationReadWriteFailed + userInfo:nil]; + NSError *invalid = [NSError errorWithDomain:NEVPNErrorDomain + code:NEVPNErrorConfigurationInvalid + userInfo:nil]; + + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(permission), + FBSocks5TunnelManagerSaveDispositionNotAuthorized); + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(wrappedPermission), + FBSocks5TunnelManagerSaveDispositionNotAuthorized); + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(readWrite), + FBSocks5TunnelManagerSaveDispositionInternal); + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(invalid), + FBSocks5TunnelManagerSaveDispositionInternal); } @end diff --git a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m index 138fd4ccc4..1a9b58d040 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m @@ -106,6 +106,26 @@ - (void)testRejectsIncompleteCredentials } } +- (void)testRejectsCredentialsAboveTheSocks5ByteLimit +{ + NSString *maxCredential = [@"a" stringByPaddingToLength:255 withString:@"a" startingAtIndex:0]; + NSString *oversizedASCII = [maxCredential stringByAppendingString:@"a"]; + NSString *oversizedUnicode = [@"é" stringByPaddingToLength:128 withString:@"é" startingAtIndex:0]; + NSString *maxCredentialURI = [NSString stringWithFormat:@"socks5://%@:pass@proxy", maxCredential]; + + XCTAssertNotNil([FBSocks5URI parse:maxCredentialURI error:nil]); + for (NSString *bad in @[ + [NSString stringWithFormat:@"socks5://%@:pass@proxy", oversizedASCII], + [NSString stringWithFormat:@"socks5://user:%@@proxy", oversizedASCII], + [NSString stringWithFormat:@"socks5://%@:pass@proxy", oversizedUnicode], + ]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); + XCTAssertNotNil(error); + XCTAssertTrue([error.localizedDescription containsString:@"255 UTF-8 bytes"], @"%@", error); + } +} + - (void)testParseErrorsDoNotExposeCredentials { NSDictionary *invalidURIs = @{ From 86a9b90539a16d0aec920e76b77d1bde26153542 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:08:25 +0200 Subject: [PATCH 23/23] fix: harden tunnel cleanup and scoped proxies --- Scripts/build-hev-socks5-tunnel.sh | 6 ++ Scripts/embed-tunnel-extension.sh | 6 ++ Scripts/remove-tunnel-extension.sh | 78 +++++++++++++++++ .../WebDriverAgentRunner-nodebug.xcscheme | 16 ++++ .../xcschemes/WebDriverAgentRunner.xcscheme | 16 ++++ .../Utilities/FBSocks5TunnelProtocol.h | 6 ++ .../Utilities/FBSocks5TunnelProtocol.m | 83 +++++++++++++++++++ WebDriverAgentLib/Utilities/FBSocks5URI.m | 7 ++ .../UnitTests/FBSocks5ConfigTests.m | 29 +++++++ .../UnitTests/FBSocks5URITests.m | 23 +++++ WebDriverAgentTunnel/FBTunnelHevRunner.h | 3 + WebDriverAgentTunnel/FBTunnelPacketProvider.m | 45 ++++------ 12 files changed, 288 insertions(+), 30 deletions(-) create mode 100755 Scripts/remove-tunnel-extension.sh diff --git a/Scripts/build-hev-socks5-tunnel.sh b/Scripts/build-hev-socks5-tunnel.sh index d193e77b8c..b36b60f565 100755 --- a/Scripts/build-hev-socks5-tunnel.sh +++ b/Scripts/build-hev-socks5-tunnel.sh @@ -1,4 +1,10 @@ #!/bin/bash +# Copyright (c) 2026-present, Droidrun. +# 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. + # Build ThirdParty/HevSocks5Tunnel.xcframework from the hev-socks5-tunnel # git submodule (ThirdParty/hev-socks5-tunnel, pinned by SHA). # diff --git a/Scripts/embed-tunnel-extension.sh b/Scripts/embed-tunnel-extension.sh index f4f5eeb4b0..d2cbd64c0b 100755 --- a/Scripts/embed-tunnel-extension.sh +++ b/Scripts/embed-tunnel-extension.sh @@ -1,4 +1,10 @@ #!/bin/bash +# Copyright (c) 2026-present, Droidrun. +# 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. + # Embed the WebDriverAgentTunnel NetworkExtension packet tunnel extension into # the wrapping XCTRunner host app. # diff --git a/Scripts/remove-tunnel-extension.sh b/Scripts/remove-tunnel-extension.sh new file mode 100755 index 0000000000..0f5ac26ff6 --- /dev/null +++ b/Scripts/remove-tunnel-extension.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Copyright (c) 2026-present, Droidrun. +# 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. + +# Remove tunnel-only artifacts from the generated XCTRunner host app. Xcode reuses the Runner.app +# directory across schemes, but the tunnel appex is copied by a scheme post-action and is therefore +# not tracked or removed by the default target build. + +set -euo pipefail + +RUNNER_APP="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}-Runner.app" +APPEX_NAME="WebDriverAgentTunnel.appex" +APPEX_DST="$RUNNER_APP/PlugIns/$APPEX_NAME" +NE_ENTITLEMENT="com.apple.developer.networking.networkextension" + +if [ ! -d "$RUNNER_APP" ]; then + echo "warning: ${PRODUCT_NAME}-Runner.app not found at $RUNNER_APP; skipping tunnel extension cleanup" + exit 0 +fi + +SIGNED=0 +SIGN_INFO="" +EXISTING_IDENT="" +RUNNER_ENTITLEMENTS_PLIST="" +if [ -d "$RUNNER_APP/_CodeSignature" ]; then + SIGNED=1 + SIGN_INFO=$(codesign -dvv "$RUNNER_APP" 2>&1 || true) + EXISTING_IDENT="${EXPANDED_CODE_SIGN_IDENTITY:-}" + if [ -z "$EXISTING_IDENT" ]; then + EXISTING_IDENT=$(awk -F'=' '/^Authority/ {print $2; exit}' <<< "$SIGN_INFO") + fi + if [ -z "$EXISTING_IDENT" ] && grep -q '^Signature=adhoc' <<< "$SIGN_INFO"; then + EXISTING_IDENT="-" + fi + RUNNER_ENTITLEMENTS_PLIST=$(mktemp -t default-runner-entitlements) + if ! codesign -d --entitlements - --xml "$RUNNER_APP" > "$RUNNER_ENTITLEMENTS_PLIST" 2>/dev/null; then + : > "$RUNNER_ENTITLEMENTS_PLIST" + fi +fi + +REMOVED_APPEX=0 +if [ -e "$APPEX_DST" ]; then + rm -rf "$APPEX_DST" + rmdir "$RUNNER_APP/PlugIns" 2>/dev/null || true + REMOVED_APPEX=1 +fi + +REMOVED_ENTITLEMENT=0 +if [ -n "$RUNNER_ENTITLEMENTS_PLIST" ] && [ -s "$RUNNER_ENTITLEMENTS_PLIST" ]; then + if /usr/libexec/PlistBuddy -c "Print :${NE_ENTITLEMENT}" "$RUNNER_ENTITLEMENTS_PLIST" >/dev/null 2>&1; then + /usr/libexec/PlistBuddy -c "Delete :${NE_ENTITLEMENT}" "$RUNNER_ENTITLEMENTS_PLIST" + REMOVED_ENTITLEMENT=1 + fi +fi + +if [ "$SIGNED" = "1" ] && { [ "$REMOVED_APPEX" = "1" ] || [ "$REMOVED_ENTITLEMENT" = "1" ]; }; then + if [ -z "$EXISTING_IDENT" ]; then + echo "warning: Runner.app is signed but no identity was discovered; signature is invalid after tunnel cleanup" + elif [ "$REMOVED_ENTITLEMENT" = "1" ]; then + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier \ + --entitlements "$RUNNER_ENTITLEMENTS_PLIST" "$RUNNER_APP" + else + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier,entitlements "$RUNNER_APP" + fi +fi + +if [ -n "$RUNNER_ENTITLEMENTS_PLIST" ]; then + rm -f "$RUNNER_ENTITLEMENTS_PLIST" +fi + +if [ "$REMOVED_APPEX" = "1" ] || [ "$REMOVED_ENTITLEMENT" = "1" ]; then + echo "removed stale tunnel artifacts from $RUNNER_APP" +fi diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme index 57692f8224..118a3312f0 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme @@ -38,6 +38,22 @@ + + + + + + + + + + + + + + + + +#include +#include +#include + NSString *const FBSocks5KeyHost = @"host"; NSString *const FBSocks5KeyPort = @"port"; NSString *const FBSocks5KeyUser = @"user"; @@ -66,6 +71,84 @@ BOOL FBSocks5TunnelAuthenticationMethodWasOffered(uint8_t method, BOOL hasCreden return 0x00 == method || (hasCredentials && 0x02 == method); } +BOOL FBSocks5ParseIPv6Address(NSString *address, NSString **literal, NSUInteger *scopeID) +{ + NSRange separator = [address rangeOfString:@"%" options:NSBackwardsSearch]; + NSString *addressPart = NSNotFound == separator.location + ? address + : [address substringToIndex:separator.location]; + NSUInteger parsedScope = 0; + if (NSNotFound != separator.location) { + NSString *zone = [address substringFromIndex:NSMaxRange(separator)]; + if (0 == zone.length) { + return NO; + } + errno = 0; + char *end = NULL; + unsigned long numericScope = strtoul(zone.UTF8String, &end, 10); + if (0 == errno && NULL != end && '\0' == *end && numericScope > 0 + && numericScope <= UINT32_MAX) { + parsedScope = (NSUInteger)numericScope; + } else { + parsedScope = (NSUInteger)if_nametoindex(zone.UTF8String); + if (0 == parsedScope) { + return NO; + } + } + } + struct in6_addr ipv6; + if (1 != inet_pton(AF_INET6, addressPart.UTF8String, &ipv6)) { + return NO; + } + if (nil != literal) { + *literal = addressPart; + } + if (NULL != scopeID) { + *scopeID = parsedScope; + } + return YES; +} + +NSString *FBSocks5IPv6AddressWithScope(NSString *literal, NSUInteger scopeID) +{ + if (0 == scopeID) { + return literal; + } + char name[IF_NAMESIZE] = {0}; + NSString *zone = NULL != if_indextoname((unsigned int)scopeID, name) + ? [NSString stringWithUTF8String:name] + : [NSString stringWithFormat:@"%lu", (unsigned long)scopeID]; + return [NSString stringWithFormat:@"%@%%%@", literal, zone]; +} + +NSString *_Nullable FBSocks5NormalizedIPAddress(NSString *_Nullable address, BOOL *isIPv6) +{ + if (0 == address.length) { + return nil; + } + NSString *candidate = address; + if (candidate.length >= 2 && [candidate hasPrefix:@"["] && [candidate hasSuffix:@"]"]) { + candidate = [candidate substringWithRange:NSMakeRange(1, candidate.length - 2)]; + } + struct in_addr ipv4; + if (NSNotFound == [candidate rangeOfString:@"%"].location + && 1 == inet_pton(AF_INET, candidate.UTF8String, &ipv4)) { + if (NULL != isIPv6) { + *isIPv6 = NO; + } + return candidate; + } + NSString *literal = nil; + NSUInteger scopeID = 0; + if (FBSocks5ParseIPv6Address(candidate, &literal, &scopeID)) { + if (NULL != isIPv6) { + *isIPv6 = YES; + } + return FBSocks5IPv6AddressWithScope(literal, scopeID); + } + return nil; +} + @interface FBSocks5TunnelStartupFence () @property (nonatomic) BOOL stopping; @property (nonatomic) BOOL startupInProgress; diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.m b/WebDriverAgentLib/Utilities/FBSocks5URI.m index d3f35dae11..4758aeb371 100644 --- a/WebDriverAgentLib/Utilities/FBSocks5URI.m +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.m @@ -110,6 +110,13 @@ + (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **) FBSocks5URISetError(error, @"The socks5 username and password must each be at most 255 UTF-8 bytes"); return nil; } + NSMutableCharacterSet *forbiddenCredentialCharacters = [NSCharacterSet.controlCharacterSet mutableCopy]; + [forbiddenCredentialCharacters formUnionWithCharacterSet:NSCharacterSet.newlineCharacterSet]; + if ((nil != user && [user rangeOfCharacterFromSet:forbiddenCredentialCharacters].location != NSNotFound) + || (nil != pass && [pass rangeOfCharacterFromSet:forbiddenCredentialCharacters].location != NSNotFound)) { + FBSocks5URISetError(error, @"The socks5 username and password must not contain control characters"); + return nil; + } FBSocks5URI *uri = [[self alloc] init]; uri.host = host; diff --git a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m index c2576764d0..b5d4dbef5d 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m @@ -9,6 +9,8 @@ #import #import +#include + #import "FBSocks5TunnelManager.h" #import "FBSocks5TunnelProtocol.h" #import "FBSocks5URI.h" @@ -103,6 +105,33 @@ - (void)testProviderConfigurationCarriesControlAddress XCTAssertEqualObjects(config[FBSocks5KeyControlAddress], @"192.0.2.20"); } +- (void)testScopedIPv6AddressPreservesAndTranslatesItsZone +{ + unsigned int loopbackScope = if_nametoindex("lo0"); + XCTAssertNotEqual(loopbackScope, 0u); + + BOOL isIPv6 = NO; + NSString *normalized = FBSocks5NormalizedIPAddress(@"fe80::1%lo0", &isIPv6); + NSString *literal = nil; + NSUInteger scopeID = 0; + + XCTAssertTrue(isIPv6); + XCTAssertNotNil(normalized); + XCTAssertTrue(FBSocks5ParseIPv6Address(normalized, &literal, &scopeID)); + XCTAssertEqualObjects(literal, @"fe80::1"); + XCTAssertEqual(scopeID, loopbackScope); + + NSString *numeric = [NSString stringWithFormat:@"fe80::2%%%u", loopbackScope]; + XCTAssertTrue(FBSocks5ParseIPv6Address(numeric, &literal, &scopeID)); + XCTAssertEqualObjects(literal, @"fe80::2"); + XCTAssertEqual(scopeID, loopbackScope); + + NSString *resolved = FBSocks5IPv6AddressWithScope(@"fe80::3", loopbackScope); + XCTAssertTrue(FBSocks5ParseIPv6Address(resolved, &literal, &scopeID)); + XCTAssertEqualObjects(literal, @"fe80::3"); + XCTAssertEqual(scopeID, loopbackScope); +} + #pragma mark - Tunnel extension presence - (NSBundle *)makeFakeRunnerBundleWithTunnelAppex:(BOOL)withAppex diff --git a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m index 1a9b58d040..2d17197cdb 100644 --- a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m +++ b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m @@ -66,6 +66,14 @@ - (void)testParsesIPv6LiteralHost XCTAssertEqual(uri.port, 1080); } +- (void)testPreservesIPv6ScopeIdentifier +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://[fe80::1%25en0]:1080" error:&error]; + XCTAssertNotNil(uri, @"%@", error); + XCTAssertEqualObjects(uri.host, @"fe80::1%en0"); +} + - (void)testRejectsUnsupportedScheme { for (NSString *bad in @[@"http://host", @"socks4://host", @"socks://host"]) { @@ -126,6 +134,21 @@ - (void)testRejectsCredentialsAboveTheSocks5ByteLimit } } +- (void)testRejectsYAMLControlCharactersInCredentials +{ + for (NSString *bad in @[ + @"socks5://line%0Abreak:pass@proxy", + @"socks5://user:tab%09password@proxy", + @"socks5://user:nel%C2%85password@proxy", + @"socks5://user:separator%E2%80%A8password@proxy", + ]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); + XCTAssertNotNil(error); + XCTAssertTrue([error.localizedDescription containsString:@"control characters"], @"%@", error); + } +} + - (void)testParseErrorsDoNotExposeCredentials { NSDictionary *invalidURIs = @{ diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.h b/WebDriverAgentTunnel/FBTunnelHevRunner.h index d8c56f28ee..0707bd67fe 100644 --- a/WebDriverAgentTunnel/FBTunnelHevRunner.h +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.h @@ -33,6 +33,9 @@ NS_ASSUME_NONNULL_BEGIN /** Asks the engine to quit and waits for the engine thread to exit. + A timed-out quit request may still be blocked inside hev's process-global state. The caller must + terminate the extension process before starting another engine generation when this returns NO. + @param timeout maximum time in seconds to wait for the engine thread @return YES when the engine stopped within the timeout */ diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m index 7037773faf..e0b1014c9c 100644 --- a/WebDriverAgentTunnel/FBTunnelPacketProvider.m +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -13,6 +13,8 @@ #include #include #include +#include +#include #import "FBSocks5TunnelProtocol.h" #import "FBTunFdFinder.h" @@ -27,8 +29,6 @@ userInfo:@{NSLocalizedDescriptionKey: message}]; } -static NSString *_Nullable FBTunnelNormalizedIPAddress(NSString *_Nullable address, BOOL *isIPv6); - // Resolves a proxy host to its literal IPs, IPv4 first (the engine's preferred family), each // family preserving resolver order, without duplicates. The engine must not resolve the host // itself: once the tunnel's DNS settings are active, an in-provider lookup would be routed @@ -61,7 +61,8 @@ } else if (AF_INET6 == entry->ai_family) { struct sockaddr_in6 *addr = (struct sockaddr_in6 *)entry->ai_addr; if (NULL != inet_ntop(AF_INET6, &addr->sin6_addr, buffer, sizeof(buffer))) { - NSString *literal = [NSString stringWithUTF8String:buffer]; + NSString *literal = FBSocks5IPv6AddressWithScope([NSString stringWithUTF8String:buffer], + addr->sin6_scope_id); if (![ipv6 containsObject:literal]) { [ipv6 addObject:literal]; } @@ -90,7 +91,7 @@ static dispatch_queue_t FBTunnelResolverQueue(void) BOOL *completed) { BOOL literalIsIPv6 = NO; - NSString *literal = FBTunnelNormalizedIPAddress(host, &literalIsIPv6); + NSString *literal = FBSocks5NormalizedIPAddress(host, &literalIsIPv6); if (nil != literal) { *completed = YES; return @[literal]; @@ -124,29 +125,6 @@ static dispatch_queue_t FBTunnelResolverQueue(void) return addresses; } -static NSString *_Nullable FBTunnelNormalizedIPAddress(NSString *_Nullable address, BOOL *isIPv6) -{ - if (0 == address.length) { - return nil; - } - NSString *candidate = address; - NSRange scopeSeparator = [candidate rangeOfString:@"%"]; - if (NSNotFound != scopeSeparator.location) { - candidate = [candidate substringToIndex:scopeSeparator.location]; - } - struct in_addr ipv4; - if (1 == inet_pton(AF_INET, candidate.UTF8String, &ipv4)) { - *isIPv6 = NO; - return candidate; - } - struct in6_addr ipv6; - if (1 == inet_pton(AF_INET6, candidate.UTF8String, &ipv6)) { - *isIPv6 = YES; - return candidate; - } - return nil; -} - /// How long the pre-flight SOCKS5 handshake may take before the proxy counts as unreachable. static const NSTimeInterval FBTunnelProbeTimeout = 8.0; /// Poll in short slices so stop requests cancel an in-flight connect or handshake promptly. @@ -305,10 +283,14 @@ Performs a SOCKS5 greeting (and username/password sub-negotiation when credentia memset(&addr, 0, sizeof(addr)); addr.sin6_family = AF_INET6; addr.sin6_port = htons(port); - if (1 != inet_pton(AF_INET6, proxyIP.UTF8String, &addr.sin6_addr)) { + NSString *literal = nil; + NSUInteger scopeID = 0; + if (!FBSocks5ParseIPv6Address(proxyIP, &literal, &scopeID) + || 1 != inet_pton(AF_INET6, literal.UTF8String, &addr.sin6_addr)) { close(fd); return FBTunnelError(@"Cannot parse the resolved SOCKS5 proxy address"); } + addr.sin6_scope_id = (uint32_t)scopeID; connected = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); } else { struct sockaddr_in addr; @@ -472,7 +454,10 @@ - (void)finishStopCleanup self.runner = nil; } - [runner stopAndWait:5.0]; + if (nil != runner && ![runner stopAndWait:5.0]) { + NSLog(@"WebDriverAgentTunnel: HEV did not stop within five seconds; terminating the extension process"); + _exit(EXIT_FAILURE); + } FBSocks5TunnelStartupFence *startupFence = self.startupFence; [self setTunnelNetworkSettings:nil completionHandler:^(NSError *_Nullable settingsError) { if (nil != settingsError) { @@ -496,7 +481,7 @@ - (void)startTunnelWithOptions:(nullable NSDictionary *) NSString *host = config[FBSocks5KeyHost]; NSNumber *port = config[FBSocks5KeyPort]; BOOL controlIsIPv6 = NO; - NSString *controlAddress = FBTunnelNormalizedIPAddress(config[FBSocks5KeyControlAddress], &controlIsIPv6); + NSString *controlAddress = FBSocks5NormalizedIPAddress(config[FBSocks5KeyControlAddress], &controlIsIPv6); BOOL remoteDNS = [config[FBSocks5KeyRemoteDNS] boolValue]; if (0 == host.length || nil == port) { [self finishStartupWithError:FBTunnelError(@"The tunnel provider configuration is missing the proxy host/port")];