<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: I. Tager</title>
    <description>The latest articles on DEV Community by I. Tager (@rcqapp).</description>
    <link>https://dev.to/rcqapp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951515%2Fb4aac436-9f60-45d8-a36c-0e66be737a10.png</url>
      <title>DEV Community: I. Tager</title>
      <link>https://dev.to/rcqapp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rcqapp"/>
    <language>en</language>
    <item>
      <title>Embedding sing-box in an iOS messenger to bypass Russian DPI (no VPN)</title>
      <dc:creator>I. Tager</dc:creator>
      <pubDate>Tue, 26 May 2026 00:10:42 +0000</pubDate>
      <link>https://dev.to/rcqapp/embedding-sing-box-in-an-ios-messenger-to-bypass-russian-dpi-no-vpn-p0e</link>
      <guid>https://dev.to/rcqapp/embedding-sing-box-in-an-ios-messenger-to-bypass-russian-dpi-no-vpn-p0e</guid>
      <description>&lt;p&gt;Embedding sing-box in an iOS messenger to bypass Russian DPI (no VPN)&lt;br&gt;
Our HTTPS API was timing out and our WebSocket refused to upgrade for a growing slice of Russian users. A carrier-level DPI was selectively killing our traffic.&lt;/p&gt;

&lt;p&gt;We rejected "tell users to install a VPN" right away. It destroys onboarding conversion, depends on a third-party app staying installed and updated, and shifts the censorship problem from us to whoever runs that VPN. We needed the bypass to live inside our app: no VPN profile, no extra step, no system permission prompt.&lt;/p&gt;

&lt;p&gt;This is what we built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we're hiding from&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three things can block us:&lt;/p&gt;

&lt;p&gt;Blanket IP block. Cheap to deploy, cheap to maintain.&lt;br&gt;
SNI inspection. The censor reads the unencrypted SNI in the TLS ClientHello and drops the connection if the host is on a blocklist.&lt;br&gt;
DPI. Pattern-matching on the wire bytes themselves, looking for protocol fingerprints regardless of destination.&lt;br&gt;
A modern Russian blocklist runs all three. So whatever we send needs to live on an IP that isn't in any "VPN provider" range, carry an SNI that points at a popular unrelated site, and look on the wire like a normal browser hitting that site.&lt;/p&gt;

&lt;p&gt;VLESS over TLS with the Reality extension hits all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why VLESS+Reality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A standard TLS-based proxy serves its own certificate for whatever fake domain the operator picked. Active probing finds the mismatch (the IP claims to be &lt;code&gt;microsoft.com&lt;/code&gt; but serves a cert with CN=&lt;code&gt;whatever.proxy&lt;/code&gt;) and the IP gets flagged.&lt;/p&gt;

&lt;p&gt;Reality skips this. The proxy doesn't present its own certificate at all. During the TLS handshake it proxies the handshake to a real upstream site. The client sees:&lt;/p&gt;

&lt;p&gt;SNI &lt;code&gt;www.microsoft.com&lt;/code&gt;&lt;br&gt;
A real Microsoft TLS certificate&lt;br&gt;
A valid TLS session&lt;br&gt;
If the censor actively probes the IP with arbitrary TLS connections, those connections also get proxied to Microsoft. They see a working microsoft.com from our IP. A client with the pre-shared Reality public key plus short ID and the &lt;code&gt;xtls-rprx-vision&lt;/code&gt; flow extension gets routed to the real VLESS endpoint behind the proxy.&lt;/p&gt;

&lt;p&gt;VLESS itself is intentionally minimal (stateless, no encryption of its own, no protocol fingerprint to match). With Reality wrapping it, the wire bytes look like a Microsoft TLS session because for the handshake portion they actually are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedding sing-box on iOS&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;sing-box is a Go-based proxy platform that speaks VLESS+Reality, Hysteria2, and a dozen other transports as both client and server.&lt;/p&gt;

&lt;p&gt;We use &lt;code&gt;gomobile bind&lt;/code&gt; to compile the parts we need into a native &lt;code&gt;.xcframework&lt;/code&gt;. The framework exposes a tiny Go API: pass it a JSON config, get back a service that owns its own listener. We start the service at app launch, point it at a local SOCKS5+HTTP inbound on &lt;code&gt;127.0.0.1&lt;/code&gt;, route the rest of the app through it.&lt;/p&gt;

&lt;p&gt;Build command, with the tags that matter:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cd ~/sing-box-src&lt;br&gt;
gomobile bind \&lt;br&gt;
  -target ios,iossimulator \&lt;br&gt;
  -tags "with_quic,with_utls" \&lt;br&gt;
  -o Rcqbox.xcframework \&lt;br&gt;
  ./mobile&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
&lt;code&gt;with_quic&lt;/code&gt; is required for Hysteria2 (more on that below). &lt;code&gt;with_utls&lt;/code&gt; is required for Reality's uTLS-based ClientHello fingerprinting. Without these tags your build silently lacks the protocols and &lt;code&gt;start()&lt;/code&gt; throws "QUIC is not included in this build" with no other hint.&lt;/p&gt;

&lt;p&gt;Do not include &lt;code&gt;with_naive_outbound&lt;/code&gt;. It pulls Cronet, and Cronet's C++ personality routine collides with libsignal_ffi's at link time on iOS. That took an evening to track down.&lt;/p&gt;

&lt;p&gt;After building, patch a nullability conflict in the generated &lt;code&gt;Rcqbox.objc.h&lt;/code&gt; header (the auto-generated &lt;code&gt;init&lt;/code&gt; is marked &lt;code&gt;nullable&lt;/code&gt; but its inherited counterpart is &lt;code&gt;nonnull&lt;/code&gt;). Without the patch Xcode complains on every clean build:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;for h in $(find Rcqbox.xcframework -name "Rcqbox.objc.h"); do&lt;br&gt;
  sed -i '' 's/- (nullable instancetype)init;/- (nonnull instancetype)init;/g' "$h"&lt;br&gt;
done&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App-level proxy, not NEPacketTunnelProvider&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;iOS has two ways to route app traffic through a tunnel.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NEPacketTunnelProvider&lt;/code&gt; is Apple's official VPN extension API. Captures all device traffic. Creates a VPN profile in Settings.&lt;/p&gt;

&lt;p&gt;App-level SOCKS proxy configures &lt;code&gt;URLSession&lt;/code&gt; to route through a local SOCKS endpoint. Only the configured URLSession's traffic gets tunneled. No VPN profile, no extra entitlement, no separate process with its own memory limits.&lt;/p&gt;

&lt;p&gt;We picked the second. We only need to tunnel our messenger's traffic, not the user's entire device. WebRTC voice calls work better with native NAT traversal, photo uploads to other services shouldn't suddenly route through Frankfurt, and the user shouldn't see a VPN icon in their status bar because of us.&lt;/p&gt;

&lt;p&gt;The URLSession side is as boring as it should be:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;let config = URLSessionConfiguration.default&lt;br&gt;
config.connectionProxyDictionary = [&lt;br&gt;
    "SOCKSEnable": 1,&lt;br&gt;
    "SOCKSProxy": "127.0.0.1",&lt;br&gt;
    "SOCKSPort": localPort,&lt;br&gt;
]&lt;br&gt;
let session = URLSession(configuration: config)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The WebSocket uses the same config. There's no UI: no toggle, no "connect" button, nothing in Settings → VPN. The app just works.&lt;/p&gt;

&lt;p&gt;sing-box config&lt;br&gt;
The VLESS+Reality outbound, stripped to essentials:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{&lt;br&gt;
  "type": "vless",&lt;br&gt;
  "tag": "relay-yandex",&lt;br&gt;
  "server": "165.22.90.214",&lt;br&gt;
  "server_port": 443,&lt;br&gt;
  "uuid": "&amp;lt;your-uuid&amp;gt;",&lt;br&gt;
  "flow": "xtls-rprx-vision",&lt;br&gt;
  "tls": {&lt;br&gt;
    "enabled": true,&lt;br&gt;
    "server_name": "www.yandex.ru",&lt;br&gt;
    "utls": { "enabled": true, "fingerprint": "chrome" },&lt;br&gt;
    "reality": {&lt;br&gt;
      "enabled": true,&lt;br&gt;
      "public_key": "&amp;lt;server-pubkey&amp;gt;",&lt;br&gt;
      "short_id": "&amp;lt;short-id&amp;gt;"&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/code&gt;&lt;br&gt;
Local inbound:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{&lt;br&gt;
  "type": "mixed",&lt;br&gt;
  "tag": "in",&lt;br&gt;
  "listen": "127.0.0.1",&lt;br&gt;
  "listen_port": 0&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;listen_port: 0&lt;/code&gt; lets the OS pick a free port. We read it back after &lt;code&gt;start()&lt;/code&gt; and feed it into URLSession's &lt;code&gt;SOCKSPort&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What bit us: the IP picks who you can reach&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We picked a Vultr instance for the first relay because it was cheap and globally distributed. It didn't work from day one. Not in the "running fine, then blocked after a week" sense. In the "TCP RST on every connection from every Russian carrier we tested" sense.&lt;/p&gt;

&lt;p&gt;The protocol was fine. The Vultr IP range was already in the blocklist. Small cloud providers are catalogued and their ranges blanket-blocked at the country edge. There's no collateral damage to legitimate users when an entire small-ISP /24 disappears, so the censor pays nothing to drop it.&lt;/p&gt;

&lt;p&gt;We skipped the "burn an IP, rotate, burn another" iteration entirely and went straight to a multi-cloud pool on major providers: DigitalOcean, Oracle Cloud Free Tier, Google Cloud, AWS Lightsail. When a major cloud's IP range gets blocked wholesale, a long tail of legitimate sites hosted on those ranges goes with it. The political cost of that collateral damage is enough to make wholesale blocking expensive for the censor, and individual IPs survive.&lt;/p&gt;

&lt;p&gt;With Reality, the protocol holds. The IP picks who you can reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't hardcode the relay&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once you accept that IPs can be blocked from day one, hardcoding the relay address/keys/SNI inside the iOS binary stops being acceptable. Every rotation means an App Store release: 24 hours minimum, 5 days worst case.&lt;/p&gt;

&lt;p&gt;We moved the relay list to a signed JSON blob on Cloudflare Workers + KV. On boot, the iOS client fetches the blob, verifies an Ed25519 signature against a public key embedded in the binary, caches the result. A bundled fallback ships in the binary so a fresh install with no network still has something to try.&lt;/p&gt;

&lt;p&gt;Rotation is: SSH into a fresh VPS, run the bootstrap script (sing-box install + Reality keypair + UUID + systemd unit, ~2 minutes), bump the source-of-truth YAML, re-sign, push to KV. Plus mirror to a GitHub raw URL because some Russian carriers blanket-block Cloudflare entirely (this surprised us — raw.githubusercontent.com sees much less Russian DPI than CF does).&lt;/p&gt;

&lt;p&gt;Clients pick up the new list on next boot or transport re-engage. No App Store release.&lt;/p&gt;

&lt;p&gt;The iOS client's &lt;code&gt;urltest&lt;/code&gt; outbound pings every relay through our &lt;code&gt;/health&lt;/code&gt; endpoint and picks the fastest, re-evaluating every 5 minutes.&lt;/p&gt;

&lt;p&gt;What this isn't&lt;br&gt;
The tunnel is not magic. The operator of the relay sees the user's traffic exactly the way the user's ISP otherwise would. Message content is end-to-end encrypted at the application layer (we use libsignal, the Signal Protocol library, for that), so the relay sees only ciphertext bound for our backend, plus metadata: which IP is talking to which IP, when, how much, with what timing. That's enough for a determined adversary to do real harm.&lt;/p&gt;

&lt;p&gt;Tunnel and privacy are two different problems solved by two different layers. Reality is about getting your bytes to the destination without the censor killing them. End-to-end encryption is about whether the destination, or anyone in between, can read those bytes. Don't conflate them, and don't tell users the tunnel is hiding their conversation. It's hiding the fact that the conversation is happening with you.&lt;/p&gt;

&lt;p&gt;By default the tunnel is off. The app tries direct first. Only if direct fails (or the user explicitly toggles it on) does sing-box engage. This keeps latency optimal for users on unrestricted networks and keeps the relay bandwidth bill manageable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Reality wasn't enough&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Days after going live with the multi-cloud relay pool, we got reports from testers on one specific carrier class in the Moscow region: whitelist DPI, where the operator allows traffic to a small allowlist of approved sites and selectively kills everything else.&lt;/p&gt;

&lt;p&gt;Sing-box logs on every relay showed the same line: &lt;code&gt;REALITY: processed invalid connection&lt;/code&gt;, immediate TCP RST. We tried changing the upstream SNI through every Western brand we had (&lt;code&gt;microsoft.com, apple.com, amazon.com&lt;/code&gt;), all killed identically. Rebuilt the Reality keypair. Freshly provisioned an IP. Killed.&lt;/p&gt;

&lt;p&gt;What was being detected wasn't the destination or the SNI. It was the Reality TLS handshake itself. Reality's uTLS mimics Chrome closely, but there are still subtle distinguishers: extension order, GREASE pattern timing, post-handshake byte distribution. Russian DPI on whitelist carriers had started matching on those.&lt;/p&gt;

&lt;p&gt;We needed a transport that didn't fingerprint as TLS at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hysteria2 alongside Reality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hysteria2 is a UDP-based transport built on QUIC with optional Salamander obfuscation: an XOR layer keyed by a shared password that masks every QUIC packet so the censor can't read the QUIC ClientHello to fingerprint it.&lt;/p&gt;

&lt;p&gt;Why this works where Reality didn't:&lt;/p&gt;

&lt;p&gt;UDP gets cheaper DPI treatment than TCP. Most censors invest pattern-matching cycles into long-lived TCP streams.&lt;br&gt;
Salamander makes the first packet look like random bytes. There's no QUIC handshake to fingerprint because every byte gets XOR'd before transmission.&lt;br&gt;
sing-box natively carries Hysteria2 once you've built with &lt;code&gt;with_quic&lt;/code&gt;.&lt;br&gt;
We added Hysteria2 inbounds alongside the existing VLESS+Reality on the same relay servers. UDP/443 next to the existing TCP/443. Sing-box happily speaks both transports on the same port number across different IP protocols. The iOS-side config got Hysteria2 outbounds in the same &lt;code&gt;urltest&lt;/code&gt; block as the VLESS ones. Whichever path responds fastest wins per connection. The loser stays warm for failover.&lt;/p&gt;

&lt;p&gt;Hysteria2 outbound, stripped:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{&lt;br&gt;
  "type": "hysteria2",&lt;br&gt;
  "tag": "relay-yandex-hy2",&lt;br&gt;
  "server": "165.22.90.214",&lt;br&gt;
  "server_port": 443,&lt;br&gt;
  "password": "&amp;lt;user-password&amp;gt;",&lt;br&gt;
  "obfs": {&lt;br&gt;
    "type": "salamander",&lt;br&gt;
    "password": "&amp;lt;obfs-password&amp;gt;"&lt;br&gt;
  },&lt;br&gt;
  "tls": {&lt;br&gt;
    "enabled": true,&lt;br&gt;
    "server_name": "www.yandex.ru",&lt;br&gt;
    "insecure": true&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
Self-signed certificate with &lt;code&gt;CN=www.yandex.ru&lt;/code&gt; on the relay, &lt;code&gt;insecure: true&lt;/code&gt; on the client. Auth happens out-of-band via the user-password and obfs-password. PKI here would give the censor another fingerprint to match.&lt;/p&gt;

&lt;p&gt;Rebuilt &lt;code&gt;Rcqbox.xcframework&lt;/code&gt; from sing-box source with &lt;code&gt;with_quic,with_utls&lt;/code&gt;, deployed the new signed relay config carrying the Hysteria2 entries (priority 0, tried first). Twenty-four hours later, one message from the affected tester: &lt;code&gt;"It's works!"&lt;/code&gt;. The same DPI that had killed every Reality variant let Hysteria2-over-Salamander through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the live setup looks like&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every relay now carries both transports:&lt;/p&gt;

&lt;p&gt;VLESS+Reality on TCP/443, SNI per relay (&lt;code&gt;microsoft.com, apple.com, amazon.com, yandex.ru&lt;/code&gt;)&lt;br&gt;
Hysteria2+Salamander on UDP/443, same SNI&lt;br&gt;
Seven entries across DigitalOcean Frankfurt, Oracle Jerusalem, GCP us-central1, AWS Singapore. The iOS client's &lt;code&gt;urltest&lt;/code&gt; picks the fastest live path per session. On a clean network the user gets a TCP path (lower latency). On a Reality-killing network they get Hysteria2, on the same relay, no switch noticeable to the user. On a network where one relay's IP is wholesale-blocked they get a different relay's TCP path. On a network where everything is blocked, they get nothing, but they're no worse off than with any other approach.&lt;/p&gt;

&lt;p&gt;Adding Hysteria2 didn't replace Reality. TCP paths are still the default on the majority of networks (lower latency, better for the WebSocket). Hy2 is the safety net for when Reality gets fingerprinted, plus a faster fallback on UDP-friendly networks.&lt;/p&gt;

&lt;p&gt;The iOS client is RCQ, an anonymous messenger (no phone number, no email, just a 9-digit UIN) currently in open beta. AGPL-3.0, source at &lt;code&gt;github.com/rcq-messenger/rcq-ios&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>networking</category>
      <category>ios</category>
      <category>russia</category>
    </item>
  </channel>
</rss>
