The full writeup behind Sharing port 443 between a real website and a VLESS+Reality tunnel. Internal specifics are scrubbed; replace <vps-ip>, DECOY_DOMAIN, and the example domains with your own.

Two complementary methods for bypassing DPI-based internet censorship using Xray-core with VLESS. Reality is the primary (lowest latency, no domain needed). Cloudflare CDN is the fallback (survives CIDR whitelisting and TLS policing). Both can coexist on the same server. The guide covers transparent proxying via TPROXY, tunneling WireGuard/NetBird over the connection, and anti-detection hardening to a level where only a full internet shutdown can suppress the link.

Table of Contents#

  1. Threat Landscape (March 2026)
  2. Architecture Overview
  3. Prerequisites
  4. VPS Provisioning & Hardening
  5. Server: VLESS+REALITY (Primary)
  6. Server: VLESS+CDN Fallback (gRPC or WebSocket)
  7. Client Setup (TUN Mode)
  8. TPROXY: Transparent Proxy (nftables)
  9. WireGuard & NetBird Through the Tunnel
  10. Anti-Detection & Operational Security
  11. Personal & Device Security
  12. Risk Assessment & Method Ranking
  13. Optional Enhancements
  14. Troubleshooting
  15. References

1. Threat Landscape (March 2026)#

These techniques are observed across multiple censorship regimes (Russia, China, Iran, Myanmar, Turkmenistan, etc.). Not all apply everywhere - check the "Scope" column.

TechniqueScopeImpact
TCP 16-20KB freezeRU mobile (Tele2, Megafon, Beeline, Yota, MTS)TSPU silently drops TCP connections after ~15-20 KB to foreign IPs (no RST, just freeze)
TLS connection-based policingRU home ISPs (MTS/MGTS, RTK, Beeline)Throttle/kill VLESS+Reality+Vision on port 443 based on connection count
CIDR whitelist (mobile)RU/TM/MM mobile operatorsOnly allow connections to whitelisted IP subnets; foreign VPS IPs blocked outright
ClientHello modificationRU some ISPsDPI modifies TLS ClientHello mid-flight, causing Xray to reject with "failed to read client hello"
Deep packet inspection (DPI)CN/RU/IR nationwideDetect fully encrypted protocols (Shadowsocks, VMess) by traffic shape
Cloudflare ECH blockingRU nationwide (since 2024-11)ECH triggers blocking; must be disabled on Cloudflare zones
Active probingCN GFW, IRCensor connects to suspected proxy servers to verify; server must respond like a real website
Reality handshake replayCN GFW (net4people #576)Replay ClientHello with valid/invalid auth -> detect different TLS stack behavior (Go vs dest site). Xray v26.2.6 vulnerable
Dual-role behavioral fingerprintState-level (FOCI 2026)Single IP acting as both server and client detected even through full encryption; 23% recall, 0.18% FP
JA3/JA4 fingerprintingCN/RU/IR DPI systemsDetect non-browser TLS clients by handshake fingerprint
Long-connection heuristicsCN/RU DPI systemsFlag persistent TLS sessions (>5 min same flow) as tunnel traffic
Full internet shutdownMM, IR (protest periods), RU (mobile during events)All international traffic blocked; only out-of-band channels survive

Why Reality is primary: No domain or CDN dependency. Direct connection with ~30-45 ms latency. Traffic is indistinguishable from visiting the decoy site (real certificate, real ServerHello). Works on most fixed-line ISPs.

Why CDN is fallback: DPI sees HTTPS to Cloudflare anycast IPs shared by millions of sites. Blocking Cloudflare would break half the internet. Survives CIDR whitelisting (Cloudflare IPs are always whitelisted). Reported uptime: 10+ months uninterrupted. Higher latency (~80-120 ms due to CDN hop).

When to use both: Run Reality as default. If your ISP starts TLS policing or CIDR-blocks your VPS IP, switch clients to the CDN path without touching the server.

When neither works (mobile CIDR whitelist): Set up a two-hop chain: cheap Russian VPS (whitelisted IP) -> foreign VPS -> internet. Intra-Russia traffic is not policed. See Section 10.9.


2. Architecture Overview#

Dual-Mode Server (One IP, One Port 443)#

                          <vps-ip>:443 (nginx stream)
                     ┌────────────┴────────────┐
                     │ ssl_preread SNI mux      │
                     │                          │
              SNI = DECOY_DOMAIN         SNI = archworks.co
              (or CDN WS path)           (or empty / IP)
                     │                          │
                     ▼                          ▼
          127.0.0.1:4443 (Xray)      127.0.0.1:8443 (nginx HTTPS)
          ├─ VLESS+Reality            ├─ Your real websites
          └─ VLESS+WS (CDN path)     └─ Camouflage pages
              Internet (freedom)
              or WARP (optional)

Client Perspective#

Reality (primary):
  Client ──VLESS+Reality+Vision──▶ VPS:443 ──▶ Internet
  DPI sees: TLS 1.3 to DECOY_DOMAIN (real cert, real handshake)

CDN (fallback):
  Client ──VLESS+WS+TLS──▶ Cloudflare CDN ──▶ VPS:443 ──▶ Internet
  DPI sees: HTTPS to Cloudflare IP (indistinguishable from any CF site)

Listener Map#

ListenerPurposeRoutes to
<vps-ip>:443/tcpNginx TCP router (stream + ssl_preread)127.0.0.1:8443 (web) or 127.0.0.1:4443 (xray)
<vps-ip>:80/tcpHTTP redirect + ACME HTTP-01local webroot
127.0.0.1:8443/tcpYour HTTPS vhosts (moved from :443)existing reverse-proxies
127.0.0.1:4443/tcpXray: VLESS+Reality + VLESS+WS (dual inbound)outbound to internet

3. Prerequisites#

Do this BEFORE censorship hits. Most steps require unrestricted internet. If a lockdown is imminent, prioritize: VPS provisioning -> Xray install -> client configs -> download APKs. Everything else can be refined later.

Preparation Checklist#

  • Provision VPS and complete Sections 4-6 (server fully working)
  • Test Reality connection from every device you'll use
  • Test CDN fallback connection from every device
  • Download client APKs/binaries to local storage (v2rayNG, Hiddify, sing-box, WireGuard)
  • Download Briar APK for mesh messaging fallback
  • Export all configs as share links / QR codes - save to encrypted storage
  • Set up full disk encryption on all devices (Section 11.5)
  • Disable QUIC and WebRTC in all browsers (Section 11.3)
  • Exchange Briar contacts in person with trusted people
  • Test kill switch behavior (Section 11.1)
  • If using home gateway: set up and test WireGuard mesh (Section 10.12)
  • Bookmark https://github.com/net4people/bbs/issues - this is where workarounds appear during active blocking events

Requirements#

  • VPS with a clean IP (not previously flagged as VPN datacenter)
  • Domain (for CDN method; a cheap .xyz/.click works, or use your existing domain)
  • Cloudflare account (free tier sufficient, for CDN fallback)
  • SSH access to the VPS
LocationProvider ExamplesLatency to MoscowNotes
Finland (Helsinki)Hetzner, UpCloud~30 msClosest geographically, best for Reality
Netherlands (Amsterdam)Vultr, DigitalOcean~40 msLargest clean IP pools
Romania (Bucharest)M247, FlokiNET~50 msPrivacy-friendly jurisdiction
Germany (Falkenstein)Hetzner CX23 (~5 EUR/mo)~45 msCheap, reliable

Avoid: US-based providers, known VPN datacenter IPs (OVH, Linode ranges are heavily fingerprinted), and providers widely used by the Russian diaspora.

IP hygiene: After provisioning, check your IP against blacklists. If curl https://check.torproject.org shows exit node status or https://www.abuseipdb.com/check/YOUR_IP shows reports, request a new IP.


4. VPS Provisioning & Hardening#

All commands work on both Arch Linux and Debian. Distro-specific steps are marked (Arch) or (Debian).

System Update#

# (Arch)
pacman -Syu --noconfirm

# (Debian)
apt update && apt upgrade -y

SSH Hardening#

sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshd
cat > /etc/sysctl.d/99-bbr.conf <<'EOF'
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
EOF
sysctl --system

Firewall Baseline#

Open SSH, HTTP, and HTTPS. Drop everything else inbound. Choose one backend:

# (Arch)
pacman -S nftables

# (Debian)
apt install -y nftables
# /etc/nftables.conf
flush ruleset

table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    iif lo accept
    tcp dport 22 ct state new accept
    tcp dport { 80, 443 } accept
    icmp type echo-request accept
  }
  chain forward {
    type filter hook forward priority 0; policy drop;
  }
  chain output {
    type filter hook output priority 0; policy accept;
  }
}
systemctl enable --now nftables

iptables#

# (Arch)
pacman -S iptables

# (Debian)
apt install -y iptables iptables-persistent
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
iptables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT

# Persist
# (Arch)
iptables-save > /etc/iptables/iptables.rules
systemctl enable --now iptables

# (Debian)
netfilter-persistent save

firewalld#

# (Arch)
pacman -S firewalld

# (Debian)
apt install -y firewalld
systemctl enable --now firewalld
firewall-cmd --set-default-zone=drop
firewall-cmd --zone=drop --add-service=ssh --permanent
firewall-cmd --zone=drop --add-service=http --permanent
firewall-cmd --zone=drop --add-service=https --permanent
firewall-cmd --zone=drop --add-icmp-block-inversion --permanent
firewall-cmd --reload

ufw#

# (Debian/Ubuntu)
apt install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Note: If you plan to use TPROXY transparent proxying (Section 8), you must use nftables or iptables. firewalld and ufw do not support the TPROXY target natively.


5. Server: VLESS+REALITY (Primary)#

5.1 Install Xray#

REALITY requires Xray (V2Ray core does not support it). Current version: v26.2.6 (February 2026).

# Both distros (official install script)
bash <(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh) install
systemctl enable --now xray
xray version  # should show 26.2.6+

Installs to:

  • Binary: /usr/local/bin/xray
  • Config: /usr/local/etc/xray/config.json
  • Service: xray.service

Note: Since v26.x, VLESS configurations must specify a flow parameter - VLESS without flow is deprecated and will be removed. Always use flow: "xtls-rprx-vision" for TCP/Reality.

5.2 Generate REALITY Keys + IDs#

xray x25519       # -> PRIVATE_KEY + PUBLIC_KEY
xray uuid         # -> UUID_1
openssl rand -hex 8  # -> SHORT_ID_1
ValueLooks likeUsed whereConfig field
REALITY PRIVATE_KEYbase64, ~43 charsServer onlyrealitySettings.privateKey
REALITY PUBLIC_KEYbase64, ~43 charsClientssing-box: tls.reality.public_key / share link: pbk=
VLESS UUIDUUID v4Server + clientclients[].id / share link: vless://UUID@...
REALITY SHORT_IDhex, 16 charsServer + clientrealitySettings.shortIds[] / share link: sid=

5.3 Choose a Decoy + RealiTLScanner Validation#

Decoy requirements:

  • Reachable from target networks (not blocked in Russia/Iran/etc.)
  • Supports TLS 1.3 + ALPN h2 consistently
  • Does not redirect (use www. variant if bare domain redirects)
  • Geographically near your VPS (ping < 15 ms from VPS to decoy) - low latency between server and dest makes the mimicry more convincing
  • Not a site overused as Reality decoy

Warning: www.microsoft.com is the #1 decoy in every tutorial. It is increasingly monitored. Prefer lesser-known but still whitelisted sites.

Good choices (validate with RealiTLScanner below):

DomainNotes
www.samsung.comLarge CDN, good global availability
addons.mozilla.orgConsistent TLS 1.3 + h2
cdn.jsdelivr.netCDN, low latency to EU datacenters
www.nvidia.comStable, not overused
www.asus.comGood h2 support

For Russia-specific mobile ISPs (if using a Russian relay VPS in a chain setup), these whitelisted Russian domains work as dest:

DomainNotes
eh.vk.com, sun6-21.userapi.comVK CDN - always whitelisted
api-maps.yandex.ruYandex - always whitelisted
smartcaptcha.yandexcloud.netYandex Cloud
io.ozone.ruOzon - major retailer

Build RealiTLScanner:

# (Arch)
sudo pacman -S --needed go git

# (Debian)
sudo apt install -y golang git

git clone https://github.com/XTLS/RealiTLScanner.git
cd RealiTLScanner && go build
sudo install -m0755 RealiTLScanner /usr/local/bin/RealiTLScanner

Validate candidates:

cat > domains.txt <<'EOF'
www.samsung.com
addons.mozilla.org
www.googletagmanager.com
cdn.jsdelivr.net
EOF

RealiTLScanner -in domains.txt -thread 50 -timeout 5 -out decoys.csv
column -s, -t decoys.csv | less -S

Pick candidates marked as "feasible." Verify manually:

DOMAIN="www.samsung.com"
openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" -tls1_3 </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
curl -I "https://${DOMAIN}/"

Sub-IP scan (only for IP ranges you own):

RealiTLScanner -addr 203.0.113.0/24 -thread 50 -timeout 3 -out subip.csv -v
# Ctrl+C after collecting enough results

5.4 Xray: VLESS+REALITY on 127.0.0.1:4443#

Xray listens locally; Nginx stream decides who reaches it.

/usr/local/etc/xray/config.json (replace placeholders):

{
  "log": { "loglevel": "warning" },
  "inbounds": [
    {
      "tag": "reality-in",
      "listen": "127.0.0.1",
      "port": 4443,
      "protocol": "vless",
      "settings": {
        "clients": [
          { "id": "UUID_1", "flow": "xtls-rprx-vision", "email": "client-1" }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality",
        "realitySettings": {
          "show": false,
          "dest": "DECOY_DOMAIN:443",
          "xver": 0,
          "serverNames": [ "DECOY_DOMAIN" ],
          "privateKey": "PRIVATE_KEY",
          "shortIds": [ "SHORT_ID_1" ]
        }
      },
      "sniffing": {
        "enabled": true,
        "destOverride": [ "http", "tls", "quic" ]
      }
    }
  ],
  "outbounds": [
    { "protocol": "freedom", "tag": "direct" }
  ]
}

Note: sniffing is required on server inbounds if you use domain-based routing rules (e.g. WARP routing in Section 10.8). Without it, Xray cannot match domains in routing rules.

The CDN inbound will be added in Section 6. For now, test Reality alone.

Known vulnerability (net4people #576): Xray's Reality protocol (as of v26.2.6) is detectable via handshake replay attack. An active prober can replay the ClientHello with valid and invalid authentication data, then observe that the server's TLS stack behavior differs between the two cases (Go's crypto/tls for authenticated clients vs the destination site's OpenSSL/BoringSSL for forwarded connections). The tolerance for "non-advancing records" (no-op TLS messages) differs between Go and OpenSSL, creating a measurable fingerprint. This is a known design limitation - when authentication fails, Reality forwards to a different TLS implementation than when it succeeds.

Mitigations:

  • Monitor the Xray-core repository for patches addressing this (acgdaily writeup)
  • Use the CDN fallback (Section 6) as primary in environments with active probing (China, Iran) - CDN connections go to Cloudflare, not your VPS, so probers can't reach your Reality inbound
  • Consider V2Ray's TLSMirror protocol as an alternative - it always forwards the carrier connection as-is regardless of authentication, eliminating this detection vector
  • The attack requires the censor to actively connect to your VPS - passive monitoring alone does not detect this. Server camouflage (Section 10.4) and restricting the VPS to CDN-only access reduce active probing exposure
xray -test -config /usr/local/etc/xray/config.json
systemctl restart xray

5.5 Nginx: Move HTTPS vhosts to 127.0.0.1:8443#

Your current HTTPS vhosts bind <vps-ip>:443. That must stop - stream will own :443.

  1. Change listen <vps-ip>:443 ssl; to listen 127.0.0.1:8443 ssl; in all vhost configs
  2. Make the homepage vhost default for IP access:
listen 127.0.0.1:8443 ssl http2 default_server;
  1. Make https://<vps-ip> show the homepage (Host override):

Add near the top of your config (near existing map blocks):

map $host $arch_upstream_host {
  default      $host;
  <vps-ip> archworks.co;
}

In the homepage vhost location / change:

proxy_set_header Host $arch_upstream_host;

If you don't have an existing site (fresh VPS), create a camouflage page:

mkdir -p /var/www/html
echo '<html><body><h1>Welcome</h1><p>Under construction.</p></body></html>' > /var/www/html/index.html

5.6 Nginx: Add stream SNI mux on Public :443#

stream {} goes at the top level of nginx.conf, not inside http {}.

Ensure the stream module is loaded:

# (Debian) install if missing
apt install -y libnginx-mod-stream
# Ensure this exists near top of /etc/nginx/nginx.conf (before events {}):
# include /etc/nginx/modules-enabled/*.conf;

# (Arch) ensure this exists near top of /etc/nginx/nginx.conf:
# include /usr/share/nginx/modules/*.conf;

In /etc/nginx/nginx.conf (top-level), add:

stream {
  include /etc/nginx/stream-enabled/*.conf;
}

Create /etc/nginx/stream-enabled/00-443-sni-mux.conf:

map $ssl_preread_server_name $backend {
  DECOY_DOMAIN               xray_tls;

  ~^(?:.*\.)?archworks\.co$  web_tls;
  "<vps-ip>"             web_tls;
  ""                         web_tls;

  default                    web_tls;
}

upstream web_tls  { server 127.0.0.1:8443; }
upstream xray_tls { server 127.0.0.1:4443; }

server {
  listen <vps-ip>:443 reuseport;
  ssl_preread on;
  proxy_pass $backend;
  proxy_timeout 24h;
  proxy_socket_keepalive on;
}

5.7 Reload + Validate#

nginx -t && systemctl reload nginx

Validate (set variables first):

VPS_IP="<vps-ip>"
WEB_SNI="archworks.co"
DECOY_SNI="www.samsung.com"  # your chosen decoy
  1. Web SNI -> web backend (should return your site):
curl -sS -o /dev/null -D- --resolve "${WEB_SNI}:443:${VPS_IP}" "https://${WEB_SNI}/" | head
  1. No SNI -> web backend (should present your web cert):
echo | openssl s_client -connect "${VPS_IP}:443" -noservername -tls1_3 2>/dev/null \
  | openssl x509 -noout -subject -issuer
  1. Decoy SNI -> Xray backend (must NOT serve your website):
timeout 6 curl -vkI --resolve "${DECOY_SNI}:443:${VPS_IP}" "https://${DECOY_SNI}/" 2>&1 | head

Expected: Step 1 shows HTTP 200, Step 2 shows your cert, Step 3 shows TLS/HTTP error or timeout (Xray expects VLESS after TLS).

Validate listeners:

ss -ltnp | grep -E ':443\b|:8443\b|:4443\b'
# :443 = nginx, 127.0.0.1:8443 = nginx, 127.0.0.1:4443 = xray

6. Server: VLESS+CDN Fallback (gRPC or WebSocket)#

This adds a second inbound to the same Xray instance, reachable via Cloudflare CDN. Your VLESS traffic is hidden inside normal HTTPS to Cloudflare, which then forwards it to your VPS origin server. Two transport options are available:

When to Use gRPC vs WebSocket#

AspectgRPCWebSocket
StealthBetter - uses HTTP/2 with application/grpc content type, blends with legitimate gRPC APIsGood - but the Upgrade: websocket header and http/1.1 ALPN are distinctive to DPI
MultiplexingNative HTTP/2 multiplexing (multiple streams over one connection)Single stream per connection; needs new WS connection for each flow
Cloudflare supportRequires enabling gRPC in Cloudflare dashboard (free tier)Enabled by default
ReliabilitySome CDNs/proxies strip gRPC headers; Cloudflare handles it wellUniversally supported by all CDNs and proxies
ResumabilitygRPC streams can be interrupted by CDN idle timeouts (~100s on CF)WebSocket has explicit ping/pong frames to keep alive
Nginx configUses grpc_pass directive (requires ngx_http_grpc_module)Uses proxy_pass with Upgrade headers
Best forPrimary CDN transport when Cloudflare is the CDNFallback if gRPC has issues; non-Cloudflare CDNs; wider compatibility

Recommendation: Use gRPC as primary CDN transport. Keep WebSocket config ready as a fallback in case your CDN provider has gRPC issues.

Emerging: XHTTP transport (Xray v26.1.23+) - uses standard HTTP POST/GET requests instead of WebSocket upgrades or gRPC, making traffic indistinguishable from normal web browsing. Supports XMUX for multiplexing with randomized parameters (maxConcurrency, hMaxRequestTimes, random padding headers). Works through any CDN (not just those supporting WS/gRPC). Responses masquerade as text/event-stream (Server-Sent Events). Xray v26.2.6 added further CDN detection bypass options. XHTTP is actively developed and may supersede gRPC/WS as the preferred CDN transport - monitor the XHTTP discussion for stabilization.

Emerging: Finalmask (Xray v26.2.6) - a "final obfuscation layer" that disguises UDP packets at the lowest level. Includes XICMP (tunnel over ICMP), XDNS (tunnel over DNS queries, similar to dnstt), and various header masquerades. Useful when all standard ports/protocols are blocked. Experimental - not yet recommended for production use.

Important: flow: xtls-rprx-vision is incompatible with both WebSocket and gRPC transports. The CDN inbound must use flow: "" (empty/omitted). Vision's anti-detection padding only works over raw TCP (Reality path). The CDN path relies on Cloudflare's cover instead.

6.1 Xray: CDN Inbound on 127.0.0.1:10086#

Generate a random gRPC service name (shared secret):

GRPC_SVC="tunnel-$(head -c 8 /dev/urandom | xxd -p)"
echo "gRPC service name: $GRPC_SVC"

Generate a separate UUID for CDN clients (or reuse the Reality UUID):

xray uuid  # -> UUID_CDN

Add the CDN inbound to /usr/local/etc/xray/config.json alongside the Reality inbound from Section 5.4. The full config has both inbounds in the same "inbounds" array:

{
  "log": { "loglevel": "warning" },
  "inbounds": [
    {
      "tag": "reality-in",
      "listen": "127.0.0.1",
      "port": 4443,
      "protocol": "vless",
      "settings": {
        "clients": [
          { "id": "UUID_1", "flow": "xtls-rprx-vision", "email": "client-1" }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality",
        "realitySettings": {
          "show": false,
          "dest": "DECOY_DOMAIN:443",
          "xver": 0,
          "serverNames": [ "DECOY_DOMAIN" ],
          "privateKey": "PRIVATE_KEY",
          "shortIds": [ "SHORT_ID_1" ]
        }
      },
      "sniffing": {
        "enabled": true,
        "destOverride": [ "http", "tls", "quic" ]
      }
    },
    {
      "tag": "grpc-cdn-in",
      "listen": "127.0.0.1",
      "port": 10086,
      "protocol": "vless",
      "settings": {
        "clients": [
          { "id": "UUID_CDN", "level": 0 }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "grpc",
        "grpcSettings": {
          "serviceName": "YOUR-GRPC-SERVICE-NAME"
        }
      },
      "sniffing": {
        "enabled": true,
        "destOverride": [ "http", "tls", "quic" ]
      }
    }
  ],
  "outbounds": [
    { "protocol": "freedom", "tag": "direct" }
  ]
}

Alternative: WebSocket transport (if you prefer or if gRPC gives issues through CF):

{
  "tag": "ws-cdn-in",
  "listen": "127.0.0.1",
  "port": 10086,
  "protocol": "vless",
  "settings": {
    "clients": [{ "id": "UUID_CDN", "level": 0 }],
    "decryption": "none"
  },
  "streamSettings": {
    "network": "ws",
    "wsSettings": { "path": "/YOUR-WS-PATH-HERE" }
  }
}
xray -test -config /usr/local/etc/xray/config.json
systemctl restart xray

6.2 Nginx: Reverse Proxy for CDN Path#

Add to your HTTPS vhost (on 127.0.0.1:8443):

For gRPC:

# VLESS gRPC reverse proxy (CDN fallback)
location /YOUR-GRPC-SERVICE-NAME {
    grpc_pass grpc://127.0.0.1:10086;
    grpc_read_timeout 86400;
    grpc_send_timeout 86400;
    grpc_socket_keepalive on;
}

For WebSocket (alternative):

# VLESS WebSocket reverse proxy (CDN fallback)
location /YOUR-WS-PATH-HERE {
    proxy_redirect off;
    proxy_pass http://127.0.0.1:10086;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout 86400;
    proxy_send_timeout 86400;
}
nginx -t && systemctl reload nginx

6.3 Cloudflare Configuration#

  1. Add your domain to Cloudflare (free plan), or use a separate cheap domain
  2. Set DNS A record: proxy.yourdomain.xyz -> YOUR_VPS_IP with orange cloud ON (proxied)
  3. SSL/TLS:
    • Mode: Full (strict)
    • Minimum TLS version: TLS 1.3
  4. Network:
    • WebSockets: ON
    • gRPC: ON (if using gRPC transport)
  5. Speed -> Optimization:
    • Disable Rocket Loader and Mirage (these can break WS/gRPC streams)
  6. Disable ECH (mandatory for Russian users - ECH triggers Roskomnadzor blocking since Nov 2024):
# Free plans cannot disable ECH via dashboard - use the API:
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/ZONE_ID/settings/ech" \
  -H "X-Auth-Email: YOUR_EMAIL" \
  -H "X-Auth-Key: YOUR_GLOBAL_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"id":"ech","value":"off"}'

Domain separation: If you don't want to move your main domain to Cloudflare, buy a throwaway .xyz for ~$1/year and use that exclusively for the CDN path. Your main domain stays on your existing DNS.

6.4 Origin Certificates (Long-Term)#

Once Cloudflare proxy is enabled, certbot renew via HTTP-01 stops working for the proxied domain. Use Cloudflare Origin Certificates instead:

  1. SSL/TLS -> Origin Server -> Create Certificate (valid 15 years)
  2. Save to the VPS:
cat > /etc/ssl/cloudflare-origin.pem <<'EOF'
-----BEGIN CERTIFICATE-----
... paste cert ...
-----END CERTIFICATE-----
EOF

cat > /etc/ssl/cloudflare-origin.key <<'EOF'
-----BEGIN PRIVATE KEY-----
... paste key ...
-----END PRIVATE KEY-----
EOF

chmod 600 /etc/ssl/cloudflare-origin.key
  1. If the CDN domain uses a separate vhost, point its ssl_certificate/ssl_certificate_key to these files

Origin certificates only work behind Cloudflare's proxy - they won't validate for direct connections, which is actually a security benefit (direct access to the VPS shows an invalid cert, not your real one).


7. Client Setup (TUN Mode)#

Default stance: Don't trust apps to obey proxy settings. Use VPN/TUN mode on all platforms so every packet goes through the tunnel.

7.1 Connection Parameters#

Reality (primary)#

OptionValue
Server<vps-ip>
Port443
UUIDUUID_1 (per device)
Flowxtls-rprx-vision
REALITY Public KeyPUBLIC_KEY
REALITY Short IDSHORT_ID_1
SNIDECOY_DOMAIN
uTLS fingerprintchrome

Share link:

vless://UUID_1@<vps-ip>:443?encryption=none&security=reality&sni=DECOY_DOMAIN&fp=chrome&pbk=PUBLIC_KEY&sid=SHORT_ID_1&type=tcp&flow=xtls-rprx-vision#archworks-reality

CDN fallback#

OptionValue
Serverproxy.yourdomain.xyz (Cloudflare-proxied)
Port443
UUIDUUID_CDN
Flow(none - Vision incompatible with gRPC/WS)
TransportgRPC (preferred) or WebSocket
gRPC Service NameYOUR-GRPC-SERVICE-NAME
TLS SNIproxy.yourdomain.xyz
uTLS fingerprintchrome

Share link (gRPC):

vless://UUID_CDN@proxy.yourdomain.xyz:443?encryption=none&type=grpc&security=tls&sni=proxy.yourdomain.xyz&serviceName=YOUR-GRPC-SERVICE-NAME&fp=chrome#archworks-cdn

Share link (WebSocket alternative):

vless://UUID_CDN@proxy.yourdomain.xyz:443?encryption=none&type=ws&security=tls&host=proxy.yourdomain.xyz&path=%2FYOUR-WS-PATH-HERE&sni=proxy.yourdomain.xyz&fp=chrome#archworks-cdn-ws

7.2 Linux (Arch/Debian): sing-box#

Install (current version: 1.13.3, March 2026):

# (Arch)
sudo pacman -Syu --noconfirm sing-box

# (Debian) - official install script
bash <(curl -fsSL https://sing-box.app/deb-install.sh)

sing-box version  # should show 1.13.3+

Config with dual outbound (Reality primary, CDN fallback):

/etc/sing-box/tunnel.json:

{
  "log": { "level": "warn" },
  "dns": {
    "servers": [
      { "tag": "remote-doh", "type": "https", "server": "1.1.1.1" },
      { "tag": "local", "type": "local" }
    ],
    "rules": [
      { "domain": [ "archworks.co", "proxy.yourdomain.xyz" ], "server": "local" }
    ],
    "strategy": "ipv4_only"
  },
  "inbounds": [
    { "type": "socks", "listen": "127.0.0.1", "listen_port": 1080, "sniff": true },
    { "type": "http",  "listen": "127.0.0.1", "listen_port": 8080 },
    {
      "type": "tun",
      "tag": "tun-in",
      "interface_name": "sb-tun0",
      "address": [ "172.19.0.1/30" ],
      "mtu": 1400,
      "auto_route": true,
      "auto_redirect": true,
      "strict_route": true,
      "sniff": true
    }
  ],
  "outbounds": [
    {
      "type": "vless",
      "tag": "reality",
      "server": "<vps-ip>",
      "server_port": 443,
      "uuid": "UUID_1",
      "flow": "xtls-rprx-vision",
      "tls": {
        "enabled": true,
        "server_name": "DECOY_DOMAIN",
        "utls": { "enabled": true, "fingerprint": "chrome" },
        "reality": {
          "enabled": true,
          "public_key": "PUBLIC_KEY",
          "short_id": "SHORT_ID_1"
        }
      }
    },
    {
      "type": "vless",
      "tag": "cdn",
      "server": "proxy.yourdomain.xyz",
      "server_port": 443,
      "uuid": "UUID_CDN",
      "tls": {
        "enabled": true,
        "server_name": "proxy.yourdomain.xyz",
        "utls": { "enabled": true, "fingerprint": "chrome" }
      },
      "transport": {
        "type": "grpc",
        "service_name": "YOUR-GRPC-SERVICE-NAME"
      }
    },
    { "type": "urltest", "tag": "auto", "outbounds": [ "reality", "cdn" ], "interval": "5m", "tolerance": 100 },
    { "type": "direct", "tag": "direct" },
    { "type": "dns",    "tag": "dns-out" }
  ],
  "route": {
    "default_domain_resolver": {
      "server": "remote-doh",
      "rewrite_ttl": 60
    },
    "rules": [
      { "protocol": "dns", "outbound": "dns-out" },
      { "domain": [ "archworks.co", "proxy.yourdomain.xyz" ], "outbound": "direct" },
      { "ip_is_private": true, "outbound": "direct" }
    ],
    "auto_detect_interface": true,
    "default_outbound": "auto"
  }
}

Key design points:

  • urltest outbound automatically switches between Reality and CDN based on latency/availability. If Reality gets throttled, traffic fails over to CDN within 5 minutes.
  • auto_redirect: true (new in 1.10+) - sing-box automatically creates nftables DNAT rules for transparent redirect. Higher performance than manual TPROXY and avoids Docker bridge conflicts. With this enabled, manual nftables TPROXY is unnecessary for sing-box users (Section 8 is for Xray-based clients or gateway machines). Warning: Do NOT use auto_redirect on machines that forward traffic for other hosts (e.g., NetBird gateway exposing corporate subnets). The nftables DNAT rules capture packets in PREROUTING, which intercepts forwarded traffic between interfaces (wt0 to ens192). This breaks DNS forwarding and potentially other forwarded services. Use auto_route: true + strict_route: true without auto_redirect on gateway machines.
  • strict_route: true prevents any leak outside the TUN.
  • address replaces the deprecated inet4_address (removed in 1.12.0).
  • DNS goes through DoH over the tunnel (no DNS leaks).
  • Server domains are routed direct to prevent routing loops.
  • gRPC transport for CDN path - avoids WebSocket's distinctive http/1.1 ALPN fingerprint.

Warning: Do not use fingerprint: "chrome_pq" (post-quantum) - it's broken with Reality (causes nil ecdhe_key error). Stick to chrome, firefox, or safari.

Breaking change (Xray v26.2.6): allowInsecure has been removed from TLS settings (auto-disabled after UTC 2026-06-01). Use pinnedPeerCertSha256 or verifyPeerCertByName for self-signed certificates instead. This does not affect Reality (which doesn't use traditional TLS certificates) or the CDN path (which uses Cloudflare's valid certificates).

Run:

sudo sing-box run -c /etc/sing-box/tunnel.json

For systemd, see Section 13.3.

7.3 Windows: v2rayN#

  1. Install v2rayN
  2. Import share links (both Reality and CDN)
  3. Set core to Xray (Reality requires Xray core)
  4. Enable TUN mode (Settings -> TUN Mode -> ON)
  5. Set routing to use the urltest/auto group

7.4 Android#

AppSupports Reality+VisionTUN/VPN ModeNotes
v2rayNGYes (Xray core)YesMost popular, import via share link or QR
v2RayTunYesYesTop-rated 2026, handles Vision flow correctly, lightweight
HiddifyYesYesOpen-source, modern UI, auto-select best server
NekoBoxYesYessing-box based, advanced routing

Setup (any app):

  1. Import share links (both Reality + CDN) via QR code or clipboard
  2. Enable VPN mode (creates Android VPN)
  3. For auto-failover: configure the app's URL test / auto-select group with both servers

Limitation: Android allows one active VPN app. Can't run v2rayNG + WireGuard/NetBird simultaneously. Workarounds:

  • Best: Run VLESS on your router/home server (Section 9.2/10.10), then use WireGuard/NetBird as the only VPN on Android
  • Rooted: Run sing-box as root TUN underneath, then WireGuard as the VPN app (Section 9.4)
  • Travel router: GL.iNet or similar running sing-box; Android connects via WiFi

Pre-install before lockdown: Download APKs of your chosen client + WireGuard + Briar while internet works. During a lockdown, app stores may be unreachable. Store APKs on SD card.

7.5 SOCKS5 / HTTP Proxy Fallback#

If TUN mode is unavailable:

# SOCKS5
curl --proxy socks5h://127.0.0.1:1080 https://ifconfig.me

# HTTP
curl --proxy http://127.0.0.1:8080 https://ifconfig.me

# Environment variables (bash)
export http_proxy="http://127.0.0.1:8080"
export https_proxy="http://127.0.0.1:8080"
export all_proxy="socks5://127.0.0.1:1080"

# Environment variables (fish)
set -Ux http_proxy "http://127.0.0.1:8080"
set -Ux https_proxy "http://127.0.0.1:8080"
set -Ux all_proxy "socks5://127.0.0.1:1080"

Proxy mode does NOT capture traffic from apps that ignore proxy settings. Use TUN/TPROXY for reliability.

7.6 Multiple Clients (UUIDs, Short IDs, Revocation)#

Two patterns:

  • Per-device: each device runs its own client. Best for mobile/roaming.
  • Site gateway: one always-on client (Linux box/VM) + OPNsense policy routing. Counts as one "client" on the server.

Per-device rules:

  • One UUID per device.
  • Optional: one Short ID per device (or per group) for granular revocation.

Generate per-device values:

xray uuid           # or: uuidgen
openssl rand -hex 8

Server: add clients:

"clients": [
  { "id": "UUID_ALICE_LAPTOP", "flow": "xtls-rprx-vision", "email": "alice-laptop" },
  { "id": "UUID_BOB_PHONE",   "flow": "xtls-rprx-vision", "email": "bob-phone" },
  { "id": "UUID_OPNSENSE_GW", "flow": "xtls-rprx-vision", "email": "opnsense-gateway" }
],
"shortIds": [ "SID_ALICE", "SID_BOB", "SID_GW" ]

Revoke a single device:

  1. Remove its UUID from "clients" (and optionally its Short ID)
  2. systemctl restart xray

If a profile leaks broadly:

  • Rotate Short IDs first (fast), then UUIDs if needed, rotate REALITY keypair last (most disruptive)

8. TPROXY: Transparent Proxy (nftables)#

Capture all traffic from the machine (or specific UIDs/interfaces) without per-app proxy configuration. Every TCP and UDP packet goes through Xray transparently.

sing-box users: If you use sing-box with auto_route: true + auto_redirect: true (Section 7.2), skip this entire section - sing-box creates its own nftables rules internally with better performance than manual TPROXY. This section is for Xray-based clients or gateway machines proxying traffic for other hosts.

8.1 Design#

Application -> kernel OUTPUT hook -> nftables marks packet -> policy route to lo
                                              nftables PREROUTING hook -> TPROXY
                                              Xray dokodemo-door (127.0.0.1:12345)
                                              VLESS outbound -> VPS -> Internet

Three chains work together:

  • OUTPUT marks outgoing packets from local processes
  • PREROUTING intercepts marked packets via TPROXY and redirects to Xray
  • DIVERT (critical, often missed) fast-paths established TCP connections that already have a transparent socket, avoiding the full TPROXY lookup on every packet

8.2 Xray dokodemo-door Inbound#

Add this inbound to Xray's client config (not the server):

{
  "tag": "tproxy-in",
  "port": 12345,
  "listen": "127.0.0.1",
  "protocol": "dokodemo-door",
  "settings": {
    "network": "tcp,udp",
    "followRedirect": true
  },
  "sniffing": {
    "enabled": true,
    "destOverride": [ "http", "tls", "quic" ],
    "routeOnly": true
  },
  "streamSettings": {
    "sockopt": {
      "tproxy": "tproxy",
      "mark": 255
    }
  }
}

The "mark": 255 (0xff) causes Xray to set SO_MARK=0xff on its outbound packets, which the nftables rules use to exclude Xray's own traffic from re-interception.

Known bug: Xray has a UDP TPROXY bug (#4791) where only the first UDP packet gets through - the inactivity timer only updates on response data, not request data. Verify your Xray version (v26.2.6+) includes the fix. If UDP is unreliable, use the GID-based bypass (Section 8.4 alternative) or sing-box TUN instead.

8.3 Policy Routing#

# IPv4
ip rule add fwmark 0x1 table 100
ip route add local 0.0.0.0/0 dev lo table 100

# IPv6 (if your network has IPv6; otherwise disable IPv6 at kernel level)
ip -6 rule add fwmark 0x1 table 106
ip -6 route add local ::/0 dev lo table 106

Persistence (most reliable method - dedicated systemd unit):

/etc/systemd/system/tproxy-routes.service:

[Unit]
Description=TPROXY policy routing rules
Before=nftables.service
After=network-pre.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/ip rule add fwmark 1 table 100
ExecStart=/usr/sbin/ip route add local 0.0.0.0/0 dev lo table 100
ExecStart=/usr/sbin/ip -6 rule add fwmark 1 table 106
ExecStart=/usr/sbin/ip -6 route add local ::/0 dev lo table 106
ExecStop=/usr/sbin/ip rule del fwmark 1 table 100
ExecStop=/usr/sbin/ip route del local 0.0.0.0/0 dev lo table 100
ExecStop=/usr/sbin/ip -6 rule del fwmark 1 table 106
ExecStop=/usr/sbin/ip -6 route del local ::/0 dev lo table 106

[Install]
WantedBy=multi-user.target
systemctl enable --now tproxy-routes

Why not systemd-networkd [RoutingPolicyRule]? networkd removes fwmark rules created by iproute2 on restart (systemd #19106) and has inconsistent behavior with custom rules. The dedicated service unit is more reliable on both Arch and Debian.

8.4 nftables Rules#

# /etc/nftables.d/tproxy.nft

define TPROXY_PORT = 12345
define MARK        = 0x1
define XRAY_MARK   = 0xff    # Must match Xray's sockopt.mark

# Complete bypass sets (RFC 5735 + RFC 4291)
define BYPASS_V4 = {
  0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
  169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24,
  192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24,
  203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4
}

define BYPASS_V6 = {
  ::1, ::ffff:0:0:0/96, 64:ff9b::/96, 100::/64,
  fc00::/7, fe80::/10, ff00::/8
}

table inet tproxy {

  # CRITICAL: fast-path for established TCP transparent sockets
  # Without this, every packet of an established connection goes through
  # the full TPROXY lookup again - massive performance hit
  chain divert {
    type filter hook prerouting priority mangle; policy accept;
    meta l4proto tcp socket transparent 1 meta mark set $MARK accept
  }

  chain prerouting {
    type filter hook prerouting priority filter; policy accept;

    # Fast-path: already-transparent TCP sockets (divert chain runs first at
    # priority mangle=-150, but its accept does not skip this chain at priority
    # filter=0; this rule ensures we don't re-TPROXY established connections)
    meta l4proto tcp socket transparent 1 accept

    # Skip loopback
    iif lo accept

    # Skip private/reserved destinations (but proxy DNS to private ranges)
    ip daddr $BYPASS_V4 meta l4proto tcp return
    ip daddr $BYPASS_V4 udp dport != 53 return
    ip6 daddr $BYPASS_V6 meta l4proto tcp return
    ip6 daddr $BYPASS_V6 udp dport != 53 return

    # Skip Xray's own outbound (marked 0xff by sockopt)
    meta mark $XRAY_MARK return

    # TPROXY everything else
    meta l4proto { tcp, udp } meta mark set $MARK tproxy ip to 127.0.0.1:$TPROXY_PORT accept
    meta l4proto { tcp, udp } meta mark set $MARK tproxy ip6 to [::1]:$TPROXY_PORT accept
  }

  chain output {
    type route hook output priority filter; policy accept;

    # Skip Xray's own outbound
    meta mark $XRAY_MARK return

    # Skip loopback, private
    oif lo accept
    ip daddr $BYPASS_V4 meta l4proto tcp return
    ip daddr $BYPASS_V4 udp dport != 53 return
    ip6 daddr $BYPASS_V6 meta l4proto tcp return
    ip6 daddr $BYPASS_V6 udp dport != 53 return

    # Mark for policy routing -> prerouting -> TPROXY
    meta l4proto { tcp, udp } meta mark set $MARK accept
  }
}

Key improvements over basic TPROXY setups:

  • Divert chain fast-paths established TCP connections (critical for performance)
  • meta mark $XRAY_MARK return matches Xray's SO_MARK=0xff from sockopt.mark: 255
  • DNS exception (udp dport != 53 return) ensures DNS queries to private ranges still get proxied
  • IPv6 support in the same inet table (no need for separate ip6tables)
  • VPS upstream IP does not need to be in bypass sets because Xray marks its own traffic with 0xff

Alternative: GID-based bypass (cleaner, avoids fwmark entirely):

Run Xray as a dedicated GID and exclude it by group instead of mark:

groupadd -r xray-tproxy
usermod -aG xray-tproxy nobody  # or whatever UID Xray runs as

Replace meta mark $XRAY_MARK return with meta skgid "xray-tproxy" return in all chains, and remove the "mark": 255 from Xray's sockopt. This eliminates all mark-based routing and reportedly performs better.

iptables Equivalent#

If you must use iptables instead of nftables (e.g. older Debian, OpenWrt):

# Policy route
ip rule add fwmark 1 table 100
ip route add local 0.0.0.0/0 dev lo table 100

# Divert chain (fast-path established transparent sockets)
iptables -t mangle -N DIVERT
iptables -t mangle -A DIVERT -j MARK --set-mark 1
iptables -t mangle -A DIVERT -j ACCEPT
iptables -t mangle -A PREROUTING -p tcp -m socket --transparent -j DIVERT

# Prerouting (TPROXY)
iptables -t mangle -A PREROUTING -m mark --mark 0xff -j RETURN
iptables -t mangle -A PREROUTING -d 127.0.0.0/8 -j RETURN
iptables -t mangle -A PREROUTING -d 10.0.0.0/8 -j RETURN
iptables -t mangle -A PREROUTING -d 172.16.0.0/12 -j RETURN
iptables -t mangle -A PREROUTING -d 192.168.0.0/16 -p tcp -j RETURN
iptables -t mangle -A PREROUTING -d 192.168.0.0/16 -p udp ! --dport 53 -j RETURN
iptables -t mangle -A PREROUTING -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 0x1/0x1
iptables -t mangle -A PREROUTING -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 0x1/0x1

# Output (mark local traffic for reroute to prerouting)
iptables -t mangle -A OUTPUT -m mark --mark 0xff -j RETURN
iptables -t mangle -A OUTPUT -o lo -j RETURN
iptables -t mangle -A OUTPUT -d 127.0.0.0/8 -j RETURN
iptables -t mangle -A OUTPUT -d 10.0.0.0/8 -j RETURN
iptables -t mangle -A OUTPUT -d 172.16.0.0/12 -j RETURN
iptables -t mangle -A OUTPUT -d 192.168.0.0/16 -p tcp -j RETURN
iptables -t mangle -A OUTPUT -d 192.168.0.0/16 -p udp ! --dport 53 -j RETURN
iptables -t mangle -A OUTPUT -p tcp -j MARK --set-mark 1
iptables -t mangle -A OUTPUT -p udp -j MARK --set-mark 1

# Persist (Arch)
iptables-save > /etc/iptables/iptables.rules && systemctl enable iptables
# Persist (Debian)
netfilter-persistent save

firewalld / ufw: TPROXY requires the mangle table with TPROXY target and socket --transparent match - these are not supported by firewalld's zone model or ufw's simplified rules. If you use firewalld, you can add the iptables rules above via firewall-cmd --direct --add-rule, but this is fragile and not recommended. For TPROXY, switch to nftables or raw iptables.

8.5 DNS Leak Prevention#

The nftables rules above already proxy DNS by excluding udp dport 53 from the private bypass (udp dport != 53 return). This means DNS queries - even to private resolvers - go through TPROXY and into Xray.

On the Xray client config, add DNS handling:

"dns": {
  "servers": [
    {
      "address": "https://1.1.1.1/dns-query",
      "domains": [ "geosite:geolocation-!cn" ]
    },
    {
      "address": "localhost",
      "domains": [ "full:archworks.co", "full:proxy.yourdomain.xyz" ]
    }
  ]
}

If you disable IPv6 at the kernel level (simplest if your VPS has no IPv6):

echo "net.ipv6.conf.all.disable_ipv6 = 1" > /etc/sysctl.d/99-no-ipv6.conf
sysctl --system

8.6 Persist & Verify#

# Load rules
nft -f /etc/nftables.d/tproxy.nft

# Include in main nftables.conf AFTER the flush+filter block
# The include must come after "flush ruleset" and the base filter table,
# otherwise flush will wipe the tproxy table on reload
echo 'include "/etc/nftables.d/tproxy.nft"' >> /etc/nftables.conf

systemctl restart nftables

Verify:

# Check all three chains loaded
nft list table inet tproxy

# Check policy routes
ip rule show | grep fwmark
ip route show table 100

# Check kernel modules
lsmod | grep -E 'nft_tproxy|nft_socket|nf_tproxy'

# Test: any app's traffic should show VPS exit IP
curl https://ifconfig.me
# Should return your VPS IP, not your real IP

# Test DNS leak
curl -s https://ipleak.net/json/ | python3 -m json.tool | grep -i dns

9. WireGuard & NetBird Through the Tunnel#

Rule: Start the VLESS client in TUN mode first, then start WireGuard/NetBird. The VPN's UDP traffic exits through the VLESS tunnel like any other traffic.

If standard WireGuard is blocked at the protocol level (DPI detects WG handshake patterns - reported in Russia, Iran, Turkmenistan): use AmneziaWG instead. AmneziaWG modifies WireGuard at the packet level - randomized headers (H1-H4), junk packets (Jc/Jmin/Jmax), variable padding (S1-S4), and concealment packets (I1-I5) - making it resistant to the DPI signatures used to block standard WG. It has survived 2+ years in Turkmenistan (net4people #523). One-command installer: amneziawg-installer (Ubuntu VPS). AmneziaWG 2.0 clients are available for all platforms via the Amnezia VPN app.

9.1 Linux: Direct Stacking (sing-box TUN + WireGuard/NetBird)#

# 1. Start sing-box (creates sb-tun0, captures all traffic)
sudo systemctl start sing-box@tunnel

# 2. Start WireGuard/NetBird normally
sudo wg-quick up wg0
sudo netbird up

sing-box's auto_route: true + strict_route: true sets up routing so all traffic (including WireGuard's UDP to its endpoint) goes through sb-tun0 first.

Avoid routing loops:

  • sing-box's route rules must send traffic to the VLESS server IP directly (not through the tunnel). The config in Section 7.2 handles this with "domain": ["archworks.co"], "outbound": "direct".
  • WireGuard's AllowedIPs should be the overlay network only (e.g. 10.x.x.0/24), not 0.0.0.0/0. If WireGuard also tries to be a full tunnel, you get two default routes fighting.

For NetBird specifically:

# If NetBird tries to manage routes that conflict:
sudo netbird up --disable-client-routes

Same-server hairpin caveat: If your VLESS server and NetBird management server are the same host, NetBird's management gRPC will hairpin through the VLESS tunnel back to itself. This can break if the server has /etc/hosts entries mapping the management domain to 127.0.0.1 (e.g., for a loopback NB client), because Xray's domainStrategy: IPIfNonMatch resolves the sniffed domain via system DNS, hits /etc/hosts, gets a private IP, and the geoip:private rule blackholes it. Fix: Add a DNS section to Xray's server config that resolves the management domain via an authoritative DNS server (bypassing /etc/hosts), and add a domain-based direct rule before geoip:private for the management domain. Same applies to Tailscale hostnames (100.x.x.x = CGNAT = geoip:private).

Gateway DNS forwarding caveat: If the tunnel client also acts as a network gateway (e.g., NetBird peer exposing corporate subnets), protocol: dns with hijack-dns in sing-box's route rules intercepts ALL DNS queries - including those forwarded by NetBird's DNS proxy to corporate nameservers. Internal domains (e.g., corp.example.com, corp.internal) will fail because sing-box resolves them via the remote DoH server (1.1.1.1) which cannot resolve private DNS zones. Fix: Add domain_suffix rules in sing-box's DNS config routing corporate domain suffixes to the dns-direct server (the corporate DNS on the bypass subnet). This ensures sing-box's DNS module resolves internal domains through the correct nameserver when it hijacks the query. Also: do NOT use auto_redirect: true on gateway machines - it captures forwarded packets in PREROUTING, breaking DNS and other forwarded traffic between interfaces.

9.2 OPNsense: Route a Network Through the Tunnel#

Most reliable for whole-network tunneling: run the VLESS client on a dedicated Linux tunnel gateway VM and let OPNsense policy-route traffic to it.

9.2.1 Tunnel Gateway VM#

Network: Create a dedicated transit network between OPNsense and the VM.

  • OPNsense: 10.99.99.1/30
  • VM: 10.99.99.2/30

On the VM:

# 1. Run sing-box in TUN mode (Section 7.2 config)
sudo systemctl start sing-box@tunnel

# 2. Enable forwarding
sudo sysctl -w net.ipv4.ip_forward=1
echo "net.ipv4.ip_forward = 1" > /etc/sysctl.d/99-forward.conf

# 3. NAT from transit interface to sb-tun0 (choose one)
TRANSIT_IF="eth0"

iptables:

sudo iptables -t nat -A POSTROUTING -o sb-tun0 -j MASQUERADE
sudo iptables -A FORWARD -i "$TRANSIT_IF" -o sb-tun0 -j ACCEPT
sudo iptables -A FORWARD -i sb-tun0 -o "$TRANSIT_IF" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Persist (Arch)
iptables-save | sudo tee /etc/iptables/iptables.rules && sudo systemctl enable iptables
# Persist (Debian)
sudo apt install -y iptables-persistent && sudo netfilter-persistent save

nftables:

# /etc/nftables.d/gateway-nat.nft
table inet gw_nat {
  chain postrouting {
    type nat hook postrouting priority srcnat; policy accept;
    oifname "sb-tun0" masquerade
  }
  chain forward {
    type filter hook forward priority 0; policy accept;
    iifname "eth0" oifname "sb-tun0" accept
    iifname "sb-tun0" oifname "eth0" ct state established,related accept
  }
}
sudo nft -f /etc/nftables.d/gateway-nat.nft

firewalld:

sudo firewall-cmd --zone=public --add-masquerade --permanent
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 -i eth0 -o sb-tun0 -j ACCEPT
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 -i sb-tun0 -o eth0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo firewall-cmd --reload

ufw:

# /etc/ufw/before.rules - add before *filter:
*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -o sb-tun0 -j MASQUERADE
COMMIT

# /etc/default/ufw - set:
DEFAULT_FORWARD_POLICY="ACCEPT"
sudo ufw reload

9.2.2 OPNsense Policy Routing#

  1. System -> Gateways -> Single -> Add

    • Interface: transit interface
    • Gateway IP: 10.99.99.2
  2. Firewall -> Rules -> (LAN or VLAN)

    • Create pass rule for the network you want tunneled
    • Set Gateway to the new transit gateway
  3. DNS leak prevention: Policy-route DNS (UDP/TCP 53) to the same gateway, or use DoH on clients.

9.3 Android: NetBird/WireGuard Over VLESS (No Dual-VPN)#

Stock Android allows one active VPN app. You can't run v2rayNG + WireGuard simultaneously.

Working setup:

  1. Run the VLESS tunnel on your network edge (OPNsense gateway, Section 9.2)
  2. Connect Android to WiFi behind OPNsense
  3. Start NetBird/WireGuard on Android normally

The phone's VPN traffic exits via your router, which sends it through the VLESS tunnel.

Mobile data / outside your network:

  • Use a travel router (GL.iNet etc.) running the VLESS tunnel; connect Android WiFi to it, then run NetBird/WireGuard over that WiFi
  • Without root, you can't reliably stack VLESS + WireGuard on one Android device

9.4 Android (Rooted): VLESS as Root TUN + NetBird/WireGuard#

Use WireGuard/NetBird as the only Android VPN app. Run VLESS underneath using root (bypasses Android's single-VPN restriction).

Requirements: Root (Magisk), working /dev/net/tun, sing-box binary for aarch64.

Install sing-box:

# Termux (if available)
pkg install sing-box

# Or manual binary
curl -L -o /data/local/tmp/sing-box "https://github.com/SagerNet/sing-box/releases/latest/download/sing-box-android-arm64"
chmod +x /data/local/tmp/sing-box

Config (/data/local/tmp/sb.json):

{
  "log": { "level": "warn" },
  "inbounds": [
    {
      "type": "tun",
      "interface_name": "sb-tun0",
      "address": [ "172.19.0.1/30" ],
      "mtu": 1280,
      "auto_route": true,
      "auto_redirect": true,
      "strict_route": true,
      "sniff": true
    }
  ],
  "outbounds": [
    {
      "type": "vless",
      "tag": "proxy",
      "server": "<vps-ip>",
      "server_port": 443,
      "uuid": "UUID_ANDROID",
      "flow": "xtls-rprx-vision",
      "tls": {
        "enabled": true,
        "server_name": "DECOY_DOMAIN",
        "utls": { "enabled": true, "fingerprint": "chrome" },
        "reality": { "enabled": true, "public_key": "PUBLIC_KEY", "short_id": "SID_ANDROID" }
      }
    },
    { "type": "direct", "tag": "direct" },
    { "type": "dns", "tag": "dns-out" }
  ],
  "dns": {
    "servers": [
      { "tag": "remote-doh", "type": "https", "server": "1.1.1.1" },
      { "tag": "local", "type": "local" }
    ],
    "rules": [
      { "domain": [ "archworks.co" ], "server": "local" }
    ],
    "strategy": "ipv4_only"
  },
  "route": {
    "rules": [
      { "protocol": "dns", "outbound": "dns-out" },
      { "domain": [ "archworks.co" ], "outbound": "direct" },
      { "ip_is_private": true, "outbound": "direct" }
    ],
    "auto_detect_interface": true,
    "default_outbound": "proxy"
  }
}

Run as root:

su -c "/data/local/tmp/sing-box run -c /data/local/tmp/sb.json"

Then start WireGuard/NetBird normally from the app.

9.5 Linux: WireGuard over wstunnel (Alternative to TUN Stacking)#

Avoids stacking two VPNs with dual default routes. wstunnel forwards the WireGuard UDP port over WebSocket, while sing-box handles all other traffic independently.

WireGuard interface
  └─ endpoint: 127.0.0.1:8080
wstunnel client
  └─ -L udp://8080:remote-wg-host:WG_PORT
  └─ remote: wss://relay.yourdomain.tld:443
sing-box (separate TUN: sb-tun0)
  └─ all other traffic -> VLESS -> VPS

Benefits:

  • WireGuard and sing-box are fully independent - no routing loop risk
  • WireGuard stays on its own interface with specific AllowedIPs
  • Works in environments that block raw UDP

wstunnel client service:

# /etc/systemd/system/wstunnel-client.service
[Unit]
Description=wstunnel (WireGuard over WebSocket)
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/wstunnel client \
  --http-headers-file /etc/wstunnel/headers \
  --dns-resolver dns+https://1.1.1.1?sni=cloudflare-dns.com \
  --connection-retry-max-backoff 120s \
  --websocket-ping-frequency 60s \
  -P v1/PATH_TOKEN \
  -L udp://LOCAL_PORT:REMOTE_WG_HOST:REMOTE_WG_PORT?timeout_sec=0 \
  wss://relay.yourdomain.tld:443
Restart=always
RestartSec=10s

[Install]
WantedBy=multi-user.target

WireGuard peer config:

[Peer]
PublicKey = PEER_PUBLIC_KEY
AllowedIPs = 10.x.x.0/24
Endpoint = 127.0.0.1:LOCAL_PORT
PersistentKeepalive = 20

Browser-like headers (/etc/wstunnel/headers):

User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Host: relay.yourdomain.tld
Pragma: no-cache
Cache-Control: no-cache

10. Anti-Detection & Operational Security#

Which subsections matter for your scenario:

Your SituationMust-Read SubsectionsCan Skip
Fixed-line ISP, moderate censorship10.1, 10.3, 10.4, 10.510.7, 10.9, 10.11, 10.12
Mobile ISP with CIDR whitelist10.1, 10.3, 10.8, 10.910.12
Life-threatening riskALL of Section 10 + ALL of Section 11Nothing
Home network gateway for family10.1, 10.3, 10.6, 10.1210.9, 10.11
Single personal device only10.1, 10.2, 10.3, 10.4, 10.710.6, 10.12

10.1 TLS Fingerprint Discipline#

DPI systems use JA3/JA4 fingerprints to identify non-browser TLS clients. A raw Go/Rust TLS stack looks nothing like Chrome.

  • Always use fingerprint: "chrome" (uTLS) in all client configs
  • sing-box: "utls": { "enabled": true, "fingerprint": "chrome" }
  • Xray share link: fp=chrome
  • Rotate between chrome, firefox, safari if you suspect fingerprint-specific blocking
  • Never use fingerprint: "random" - random fingerprints are themselves a signal

Verify from the server side:

# On VPS: capture a client handshake and check JA3
sudo tcpdump -i eth0 -c 1 -w /tmp/handshake.pcap 'tcp port 443 and tcp[((tcp[12:1] & 0xf0) >> 2):1] = 0x16'
# Analyze with ja3 tools or Wireshark

10.2 Traffic Pattern Obfuscation#

DPI doesn't just look at the handshake - it performs statistical analysis on the entire session. These are the detection vectors and how each layer addresses them:

10.2.1 Detection Vectors & Mitigations#

PatternDetection RiskHow Reality+Vision MitigatesCDN Path Mitigation
Constant bitrateHigh - real HTTPS is burstyVision inserts random-length padding between TLS records, creating variable inter-packet gapsCloudflare's edge multiplexes your stream with other traffic on the same connection
Symmetric upload/download ratioHigh - normal browsing is ~90% download, VPN is ~50/50Vision does NOT fix this - it only pads, not reshapesSame problem; mitigate by routing only outbound-heavy traffic (browsing, streaming) through tunnel
Large sustained flowsHigh - normal HTTPS sessions are short-livedReality makes them indistinguishable from legitimate CDN streams; session chopping (Section 10.7) available but not recommendedCloudflare terminates idle connections after ~100s; use gRPC keepalives
Single long-lived TCP connectionMediumReality + Vision's per-record padding; long-lived TLS to CDN IPs is normal (push, SSE, gRPC)gRPC streams are inherently multiplexed; CF adds its own connection management
Consistent packet sizesMedium - VPN tunnels produce fixed-MTU packetsVision adds random padding (1-255 bytes) to each TLS record, breaking fixed-size patternsCloudflare's TLS record sizes vary naturally
Packet timing regularityMedium - VPN keepalives are clock-regularVision does NOT add timing jitter; WireGuard keepalives blend with normal TLS keepalivesgRPC/WS pings add slight jitter naturally
High connection count to single IPMediumN/A (single connection per client)CDN: client connects to Cloudflare anycast, not your IP
TLS record size distributionLow-MediumVision's padding makes the distribution match real h2 trafficCloudflare's own TLS framing

10.2.2 What Vision Actually Does (and Doesn't)#

Vision (xtls-rprx-vision) operates at the inner TLS layer:

  • Does: Adds random padding to each TLS Application Data record (1-255 bytes). This eliminates the "TLS-in-TLS" fingerprint where inner TLS records would be visible as fixed-size chunks inside the outer TLS stream. Also randomizes the initial handshake padding to defeat "first N bytes" classifiers.
  • Does NOT: Add timing jitter between packets, reshape upload/download ratios, or break up large transfers into smaller bursts. These are traffic-shaping problems that Vision doesn't address.

10.2.3 The VPN-Over-Tunnel Problem#

When you run WireGuard or NetBird over the VLESS tunnel (Section 9), you add another layer of identifiable patterns:

WireGuard/NetBird PatternRiskMitigation
25-second keepalive (PersistentKeepalive=25)Medium - regular 148-byte packets every 25sIncrease keepalive interval to 45-60s or disable if NAT allows. The regularity is a signal.
Fixed 80-byte handshake initiationLow - happens only on reconnectInfrequent enough to be noise
Symmetric MTU-sized packets during transferMedium - VPN payload + WG overhead = consistent sizesVision's padding helps but doesn't fully mask it. Avoid sustained bulk transfers if possible.
UDP inside TCP (WG-UDP tunneled through VLESS-TCP)Low - DPI can't see inside the TLS streamNo action needed - the outer layer is opaque

Practical advice for VPN-over-tunnel:

  • Set WireGuard PersistentKeepalive = 45 (or higher) instead of the default 25
  • Use AllowedIPs to route only specific networks (not 0.0.0.0/0) through WireGuard - this reduces the traffic volume and makes the pattern less VPN-like
  • If using NetBird: netbird up --disable-client-routes prevents NetBird from adding routes that create a full tunnel
  • Avoid long sustained downloads (torrents, large file transfers) through the VPN-over-tunnel stack - the constant bitrate is the biggest signal

10.2.4 Upload/Download Ratio Problem (Hardest to Fix)#

Normal HTTPS browsing is heavily asymmetric: ~5-10% upload, ~90-95% download. A VPN tunnel carrying two-way traffic (especially with WireGuard keepalives and ACKs) shifts toward ~30-40% upload. This is one of the strongest statistical signals for tunnel detection.

Mitigations (none are perfect):

  1. Route only browsing/streaming through the tunnel (download-heavy) - upload-heavy activities (video calls, file uploads) over direct connection if safe
  2. Padding-based ratio inflation - not currently supported by Xray or sing-box; would require injecting dummy download data
  3. Accept the risk - most DPI systems don't perform per-flow ratio analysis in real-time (too expensive). This is more of an offline forensic risk.
  4. CDN path helps - Cloudflare multiplexes your traffic with legitimate HTTPS, which dilutes the ratio signal at the IP level

Mux vs Vision: mux (multiplexing) is incompatible with xtls-rprx-vision flow. Vision operates at the TLS layer; mux operates at the application layer - they conflict. When using Reality+Vision (primary path), do NOT enable mux. The CDN path (gRPC/WS, no Vision) can benefit from mux, but Cloudflare's own multiplexing already provides similar benefits.

Exception: On ISPs with TLS connection-count policing (net4people #546), removing Vision + enabling mux reduces active connection count below the detection threshold. This is a targeted workaround, not a general recommendation.

10.3 DNS Hygiene#

DNS leaks reveal which sites you visit even if the connection is encrypted. Recent research (Lange et al., FOCI 2026) found that even browsers' built-in encrypted DNS is vulnerable to censorship because they (1) send an unencrypted bootstrap query for the resolver's domain and (2) always include SNI in the encrypted connection - both of which the censor can filter.

Client-side:

  • Configure DoH/DoT by IP address, not domain - this eliminates the initial unencrypted bootstrap DNS query that reveals you're using encrypted DNS. All major resolvers support this:
    • Cloudflare: 1.1.1.1 (DoH on port 443, DoT on port 853)
    • Google: 8.8.8.8
    • Quad9: 9.9.9.9
  • Omit SNI in encrypted DNS connections - no public resolver requires SNI. Most resolvers serve IP certificates, so certificate validation works without SNI. sing-box and Xray do this automatically when you configure by IP.
  • DoQ (DNS over QUIC) works in Iran - universally unblocked as of 2026. Adguard and NextDNS resolvers support DoQ. If DoH/DoT is blocked, try DoQ on port 853/UDP.
  • sing-box config already routes DNS through the tunnel (Section 7.2)
  • Never use your ISP's DNS resolver
  • Disable QUIC and WebRTC in browsers (Section 11.3) - QUIC can bypass the tunnel; WebRTC leaks local IPs via STUN
  • DPYProxy-DNS (github.com/UPB-SysSec/DPYProxy) - automated tool that tries all DNS circumvention methods (UDP last-response, TCP segmentation, DoT, DoH, DoH3, DoQ) and finds one that works. Useful as a local resolver on the tunnel gateway.

Browser DNS pitfalls:

Browsers that use DoH (Firefox, Chrome) have a critical flaw: they first do an unencrypted UDP query to resolve the DoH resolver's domain (e.g. mozilla.cloudflare-dns.com). This plaintext query reveals your intent to use encrypted DNS, and censors can block it. Fix: Set the system DNS resolver to the DoH server's IP address directly - this bypasses the bootstrap query entirely.

Server-side:

  • The VPS should use a privacy-respecting resolver:
# /etc/systemd/resolved.conf
[Resolve]
DNS=1.1.1.1#cloudflare-dns.com
DNSOverTLS=yes

Verification:

# Should show VPS IP, not your real IP
curl https://ipleak.net/json/
# Check DNS leak
curl https://browserleaks.com/dns

10.4 Server Camouflage#

The VPS must look like a normal web server to active probers. An empty nginx default page or a server that only responds to VLESS is a red flag that invites deeper inspection.

Requirements:

  • Serve a real, believable website on port 443 - not a placeholder
  • Respond correctly to all SNIs: Unknown SNIs get your web backend, not a connection reset
  • HTTP/80 works: Return a 301 redirect. Blocked port 80 is suspicious
  • Reality dest site must match: DPI probers will connect to your IP with the decoy SNI - Reality handles this automatically (proxies them to the real decoy site)
  • Don't run other suspicious services on the same IP (Tor, Shadowsocks, OpenVPN)
  • Maximum 5-10 users per VPS - high traffic volume to a single IP is a signal

Cover story websites (choose one that matches a plausible reason to host a server):

Cover StoryWhat to DeployWhy It's Convincing
Personal portfolio/CVStatic HTML5 site (Hugo, Jekyll)Common for developers, explains server ownership
Hobby blogWordPress, Ghost, or static siteNatural traffic pattern (occasional visitors)
Personal wiki/notesBookStack, Wiki.js, DokuWikiExplains persistent connections (you "use it regularly")
Photography portfolioStatic gallery (Photoswipe, Lightbox)Explains larger file transfers
Small business siteLanding page + contact formMost common type of website on VPS
Open-source project pageGitHub Pages mirror, Read the Docs cloneCommon among technical users

Deploy a real cover site:

# Example: Hugo static site (fast, minimal)
# (Arch)
pacman -S hugo

# (Debian)
apt install -y hugo

hugo new site /var/www/cover
cd /var/www/cover
git init
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke themes/ananke
echo 'theme = "ananke"' >> hugo.toml
hugo new posts/hello-world.md
# Edit content, then build
hugo -D
# Point nginx root to /var/www/cover/public/

Update nginx to serve the cover site:

root /var/www/cover/public;
index index.html;

Ongoing maintenance: Update the site occasionally (new blog post, updated CV). A site that hasn't changed in 6 months looks abandoned. Set up a cron job or script to touch the modification date periodically.

Active probe test (simulate what the censor does):

# From outside: should look like a normal website
curl -I https://YOUR_VPS_IP
curl -I https://archworks.co

# With decoy SNI: should look like the real decoy site
openssl s_client -connect YOUR_VPS_IP:443 -servername DECOY_DOMAIN -tls1_3 </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
# Should show the decoy's certificate, not yours

Dual-Role Behavioral Fingerprint (FOCI 2026 - Critical)#

Recent research (Almutairi et al., FOCI 2026) demonstrates that censors can detect proxy VPS servers even with perfect traffic obfuscation by observing a "dual-role" pattern: the same IP simultaneously acts as a server (receiving client connections) and a client (initiating outbound connections to user-facing destinations like news sites, social media). Normal servers only receive; normal clients only initiate. A relay does both. This architectural fingerprint identified 23% of known relays with only 0.18% false positive rate against benign TLS servers.

The detection pipeline:

  1. Candidate selection: Focus on IPs in VPS-dense ASNs (Hetzner, Vultr, DigitalOcean - exactly where this guide recommends hosting)
  2. Dual-role detection: Flag IPs that both receive inbound connections AND initiate outbound connections within the same observation window
  3. Suspicion scoring: Weight outbound destinations - accessing news/social media = high suspicion; accessing infrastructure/APIs = low suspicion

This means: Even if your VLESS+Reality traffic is indistinguishable from legitimate HTTPS, the fact that your VPS receives connections from clients AND makes connections to YouTube, Twitter, Signal etc. reveals it as a relay.

Mitigations:

StrategyHow It Breaks the Dual-Role PatternAlready in Guide?
WARP as exitVPS outbound goes to Cloudflare WARP SOCKS (infrastructure), not user-facing domains. Suspicion score stays low.Section 10.8
CDN path onlyClient -> Cloudflare -> VPS -> WARP. The VPS never touches user-facing domains directly.Section 6 + 10.8
Split architectureSeparate entry VPS (receives clients) from exit VPS (accesses internet). Entry never initiates outbound; exit never receives inbound.Section 10.9 (partial)
Serve a real websiteThe VPS has legitimate inbound AND outbound traffic patterns (web crawlers, API calls, updates) that dilute the relay signal.Section 10.4

Strongest defense: Combine WARP as exit (all user-facing traffic exits through Cloudflare, not your VPS IP) with a real website (creates legitimate server-role traffic). The VPS's outbound connections go only to Cloudflare WARP (127.0.0.1:40000) and infrastructure (apt, DNS, NTP) - no user-facing domains at all. The dual-role detector sees a normal web server that occasionally talks to Cloudflare.

10.5 Rotation Schedule#

WhatFrequencyImpact of Rotation
Short IDMonthly or on suspicionLow - just update client configs
UUIDEvery 2-3 monthsMedium - regenerate share links
WS Path (CDN)Every 2-3 monthsMedium - update client + nginx
REALITY keypairOnly if compromisedHigh - all clients need new public key
Decoy domainIf decoy gets flaggedMedium - update server + all clients
VPS IPIf blockedHigh - DNS updates, new Cloudflare origin

Monitor connection success rates. A sudden drop in Reality connections while CDN still works means your VPS IP or decoy SNI is being targeted.

10.6 Egress Lockdown (Prevent Leaks)#

On machines running both the tunnel and WireGuard/NetBird, lock down egress so nothing bypasses the tunnel:

# /etc/nftables.d/egress-lockdown.nft

table inet egress_guard {
  # Named sets for UID matching
  set tunnel_uids {
    typeof meta skuid
    elements = { "nobody", "sing-box" }    # UIDs that may talk to the internet directly
  }

  set locked_uids {
    typeof meta skuid
    elements = { "netbird", "wstunnel" }   # UIDs that must go through the tunnel only
  }

  chain output {
    type filter hook output priority 0; policy accept;

    # Tunnel stack itself: allow direct internet
    meta skuid @tunnel_uids accept

    # Locked services: loopback only
    meta skuid @locked_uids oif lo accept
    meta skuid @locked_uids drop

    # Everything else goes through the tunnel (TUN/TPROXY handles routing)
  }
}
nft -f /etc/nftables.d/egress-lockdown.nft

iptables equivalent:

# Allow tunnel stack UIDs to reach internet directly
iptables -A OUTPUT -m owner --uid-owner nobody -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner sing-box -j ACCEPT

# Locked UIDs: loopback only
iptables -A OUTPUT -m owner --uid-owner netbird -o lo -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner netbird -j DROP
iptables -A OUTPUT -m owner --uid-owner wstunnel -o lo -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner wstunnel -j DROP

firewalld / ufw: Neither supports UID-based output filtering (--uid-owner). If you use firewalld, add these as --direct rules. ufw cannot do this at all - use nftables or iptables for egress lockdown.

10.7 Session Chopping (Anti Long-Connection Heuristics)#

Status: DISABLED (recommended). Session chopping provides marginal detection resistance while causing real operational problems. See analysis below. Kept here for documentation - enable only if your specific ISP does connection-duration policing.

The theory: Normal HTTPS sessions last seconds to low minutes (page load -> idle -> close). A VPN tunnel maintains a single TLS session for hours or days. DPI systems flag sessions exceeding ~5 minutes with sustained data transfer as probable tunnels. Some Russian ISPs (net4people #546) count concurrent TLS connections from a single source IP to a single destination - if the count stays at 1 for hours, it's flagged.

The technique: Periodically tear down and re-establish the tunnel connection, with random jitter to avoid creating a new detectable pattern.

Why This Is Largely Ineffective#

Flow stitching defeats chopping trivially. A censor observing the connection sees:

Session 1: gateway -> relay:443 (TLS, cloudflare SNI, 14min)
  [2.3s gap]
Session 2: gateway -> relay:443 (TLS, cloudflare SNI, 7min)
  [1.1s gap]
Session 3: gateway -> relay:443 (TLS, cloudflare SNI, 22min)

Same source IP, same destination IP, same SNI, same port, immediate reconnect after every drop. Any flow analysis tool stitches these into one logical session. The gaps don't help - they actually make the pattern more suspicious because real browsers don't drop and instantly reconnect to the same host in a loop.

What actually protects the tunnel:

  1. Reality - the TLS handshake is cryptographically indistinguishable from a real cloudflare.com connection. The censor cannot prove it's not legitimate without blocking cloudflare entirely.
  2. The destination IP - if the relay IP isn't already flagged, nobody scrutinizes the connection. Censors work from blocklists, not by analyzing every TLS session.
  3. Traffic volume - one persistent TLS connection to a CDN-looking IP is normal. Millions of devices maintain long-lived connections (push notifications, SSE, websockets, gRPC streams).

A censor who can't break Reality won't catch you regardless of session length. A censor who can do flow stitching won't be fooled by chopping.

Operational Problems#

  • Kills SSH sessions - if NetBird/WireGuard runs inside the VLESS tunnel (the recommended setup), every chop drops the overlay, which drops SSH sessions through the mesh
  • NetBird management reconnect delay - gRPC stream has its own reconnect backoff (up to ~30s), during which peer routes are stale
  • Creates a detectable pattern - periodic reconnects to the same IP are themselves a fingerprint

Graceful Alternative (If You Still Want It)#

Instead of hard-restarting sing-box, cycle the outbound connection via iptables - the TUN interface stays up, NetBird/WireGuard re-handshake over the brief packet loss, SSH survives via TCP retransmit:

#!/bin/bash
# /usr/local/bin/tunnel-chop.sh
# Randomized connection cycling - forces fresh TLS session
# without killing TUN/NetBird/SSH

RELAY_IP="<vps-ip>"
DROP_MS=$(( 500 + RANDOM % 3000 ))  # 0.5-3.5 seconds random

iptables -I OUTPUT -d "$RELAY_IP" -j DROP
sleep "$(echo "scale=3; $DROP_MS/1000" | bc)"
iptables -D OUTPUT -d "$RELAY_IP" -j DROP

# sing-box detects connection loss, reconnects with fresh TLS session
# TUN stays up, WireGuard re-handshakes, SSH retransmits over gap
# /etc/systemd/system/tunnel-chop.service
[Unit]
Description=Graceful VLESS connection cycling

[Service]
Type=oneshot
ExecStart=/usr/local/bin/tunnel-chop.sh
# /etc/systemd/system/tunnel-chop.timer
[Timer]
OnBootSec=5m
OnUnitActiveSec=30m
RandomizedDelaySec=10m
AccuracySec=30s
Persistent=true

[Install]
WantedBy=timers.target

The original technique restarts sing-box entirely, killing all connections:

# /etc/systemd/system/tunnel-chop.service
[Unit]
Description=Jittered restart for VLESS tunnel

[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl try-restart sing-box@tunnel
# /etc/systemd/system/tunnel-chop.timer
[Timer]
OnBootSec=30s
OnUnitActiveSec=3m
RandomizedDelaySec=120s
AccuracySec=1s
Persistent=true

[Install]
WantedBy=timers.target

Impact on VPN-over-tunnel: WireGuard/NetBird connections die on every restart. WireGuard re-handshakes when transport returns (1-2s), but NetBird management gRPC needs up to 30s to re-establish. All SSH/interactive sessions through the overlay are killed.

When to Enable#

  • Your ISP is confirmed to do connection-duration policing (net4people #546: MTS/MGTS, RTK, Beeline)
  • You observe throttling specifically correlated with connection duration
  • You have no interactive sessions through the tunnel (batch-only traffic)

When to Leave Disabled (Default)#

  • Most ISPs don't do connection-time policing
  • You run SSH, VoIP, or other interactive traffic through the tunnel
  • Reality already provides sufficient detection resistance
  • You're using the CDN path (Cloudflare manages connections itself)

10.8 Cloudflare WARP Chaining#

Some services (OpenAI, Google, etc.) block commercial VPS IP ranges. Chain WARP as exit on the server so clients appear to come from a residential Cloudflare IP:

# (Debian VPS)
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \
  | gpg --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(lsb_release -cs) main" \
  | tee /etc/apt/sources.list.d/cloudflare-client.list
apt update && apt install -y cloudflare-warp

# (Arch VPS - AUR)
paru -S cloudflare-warp-bin

warp-cli registration new
warp-cli mode proxy
warp-cli proxy port 40000
warp-cli connect

Add WARP outbound to Xray server config:

{
  "tag": "warp",
  "protocol": "socks",
  "settings": {
    "servers": [{ "address": "127.0.0.1", "port": 40000 }]
  }
}

Route specific domains through WARP:

{
  "type": "field",
  "domain": [
    "openai.com", "chatgpt.com", "anthropic.com", "claude.ai",
    "gemini.google.com", "perplexity.ai", "netflix.com",
    "geosite:category-gov-ru", "regexp:.*\\.ru$"
  ],
  "outboundTag": "warp"
}

Also route geoip:ru through WARP to avoid "border double-crossing" (traffic exiting a Russian IP, crossing to VPS, then back to a Russian destination - this pattern is flagged).

10.9 Two-Hop Chain (Mobile CIDR Whitelist Bypass)#

When mobile operators whitelist only domestic IP ranges, direct connections to foreign VPS fail entirely. Solution: chain through a domestic relay.

Client -> Domestic VPS (whitelisted IP) -> Foreign VPS -> Internet
         ├─ VLESS+Reality to relay        └─ VLESS+Reality to exit
         └─ Intra-domestic traffic is NOT policed by DPI

Setup:

  1. Rent a cheap domestic VPS (in the censored country itself)
  2. Install Xray on the domestic VPS as a relay (inbound: VLESS+Reality, outbound: VLESS+Reality to foreign VPS)
  3. Client connects to the domestic VPS; the domestic VPS forwards to the foreign exit

The relay's Xray config chains outbound:

{
  "outbounds": [
    {
      "tag": "chain-exit",
      "protocol": "vless",
      "settings": {
        "vnext": [{
          "address": "FOREIGN_VPS_IP",
          "port": 443,
          "users": [{ "id": "UUID_CHAIN", "flow": "xtls-rprx-vision", "encryption": "none" }]
        }]
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality",
        "realitySettings": {
          "serverName": "DECOY_DOMAIN",
          "publicKey": "FOREIGN_VPS_PUBLIC_KEY",
          "shortId": "FOREIGN_SID",
          "fingerprint": "chrome"
        }
      }
    }
  ]
}

For domestic decoy domains (Russian mobile whitelists): use VK CDN (eh.vk.com), Yandex (api-maps.yandex.ru), or other locally whitelisted domains from Section 5.3.

10.10 Home Server Bridge (Fixed-Line Fallback for Mobile Lockdowns)#

When mobile internet is CIDR-locked but home broadband still works (common pattern - ISPs apply different policies to fixed vs mobile), use your home connection as a bridge:

Phone (mobile, locked down)
  └─ WireGuard/NetBird VPN -> Home server (10.x.x.x, fixed-line ISP)
                                └─ sing-box TUN -> VLESS+Reality -> Foreign VPS -> Internet

Setup:

  1. A small always-on device at home (Raspberry Pi, old laptop, NUC) running sing-box
  2. WireGuard/NetBird endpoint exposed on the home network
  3. Phone connects via WireGuard to home, then exits through the VLESS tunnel

This works because:

  • WireGuard between phone and home server is intra-ISP (or at worst intra-country) - not subject to international DPI
  • The VLESS tunnel runs from the home server's fixed-line connection, which has fewer restrictions than mobile
  • The phone only needs to reach its home IP, which is always whitelisted

On Android: run WireGuard or NetBird as the VPN app. The home server handles the censorship bypass transparently.

10.11 Last Resort: Out-of-Band & Offline Channels#

When internet access is completely cut (full shutdown, as seen in Myanmar 2021, Iran 2022, or during coups), these alternatives provide at least messaging capability:

MethodBandwidthLatencyRangeDetection RiskNotes
Briar (mesh, Bluetooth/WiFi)~1 KB/sSeconds-minutes~100m per hopVery lowP2P, no internet needed, Tor when internet available
Meshtastic (LoRa radio)0.2-5 KB/sSeconds1-16 km per nodeLowEncrypted, license-free ISM band, ~$30 hardware
Satellite (Starlink)50+ Mbps20-40 msGlobalHigh (dish visible)Expensive, physically detectable, requires clear sky
SMS Gateway (encrypted)~140 bytes/msg1-30 secCellular rangeMediumWorks if SMS still functions; see below
Sneakernet (physical media)N/AHours-daysPhysical transportVery lowUSB drives, SD cards, dead drops

SMS Gateway for Messaging#

If cellular SMS still works (often the last thing shut down - it's needed for government alerts and banking OTPs):

Phone (in censored area)
  └─ Encrypted SMS -> Gateway phone/SIM (in uncensored area)
                       └─ Signal/Matrix/Telegram bot -> Internet

Implementation:

  1. Gateway side (uncensored location): Android phone with SIM, running SMSSync or a custom Tasker/Termux script that:

    • Receives SMS from known sender numbers
    • Decrypts the payload (pre-shared AES key via Signal before lockdown)
    • Forwards to a Matrix/Telegram bot or webhook
    • Relays responses back via SMS
  2. Client side (censored location): Send AES-encrypted base64-encoded messages via SMS to the gateway number

  3. Encryption: Pre-exchange a symmetric key (AES-256-GCM) before the lockdown via Signal or in person. Messages are base64(AES-GCM(plaintext)) sent as SMS.

This provides ~100-140 bytes of cleartext per SMS after encryption overhead. Enough for short text messages, GPS coordinates, or status updates. Not enough for browsing.

Briar (P2P Mesh Messaging)#

Briar works over:

  • Bluetooth (device-to-device, ~10m range)
  • WiFi (direct or local network)
  • Tor (when internet is available)

Install on Android before the lockdown. Exchange contacts in person. Messages sync when devices are in proximity. No servers, no internet required for local communication.

10.12 LAN Pivot Detection & Internal Traffic Hiding#

When a gateway device on your LAN tunnels traffic for other devices (Section 9.2, 10.10), the internal network develops a pivot point pattern - the same traffic signature that network defenders use to detect lateral movement by attackers. If your home router is ISP-provided (with telemetry), compromised, or the LAN itself is monitored, this is a serious detection vector.

10.12.1 What the Observer Sees#

An entity monitoring your LAN (compromised router, ISP CPE telemetry, a tap on the switch) can detect:

SignalWhat It RevealsRisk
Traffic volume anomalyOne device suddenly generates 10x its normal trafficHigh - immediate flag
Fan-in patternMultiple LAN devices send traffic to one gateway device, which forwards it externallyHigh - classic pivot/proxy signature
Temporal correlationBursts from device A to gateway correlate with bursts from gateway to VPS IPHigh - statistical linkage
ARP/MAC changesGateway starts responding to ARP requests for the default gateway (if doing transparent proxy)Medium - visible to any device on the LAN
TTL anomalyNAT'd traffic from the gateway has decremented TTL compared to direct trafficLow - requires deep packet inspection on the LAN
New traffic flowsDevices that previously had diverse destination IPs now all flow through one LAN IPHigh - trivially detectable
Protocol shiftAll traffic from the gateway is HTTPS to one IP (VPS), no mixed HTTP/DNS/etc.Medium - unusual for a normal device

10.12.2 Mitigation Strategies#

Strategy 1: No shared gateway - each device tunnels independently (best)

Eliminate the pivot point entirely. Every device that needs censorship bypass runs its own VLESS client:

  • Laptops/desktops: Each runs sing-box with TUN mode (Section 7.2)
  • Phones: Each runs v2rayNG/Hiddify (Section 7.4)
  • IoT/devices that can't run a client: Accept that these can't be tunneled, or use Strategy 3

LAN traffic is normal - each device makes its own HTTPS connection to the VPS (or Cloudflare CDN). No single device carries disproportionate traffic. Use different UUIDs and Short IDs per device (Section 7.6) so they appear as independent connections.

This is the recommended approach. It has the lowest detection footprint because each device's traffic pattern matches normal browsing.

Strategy 2: Encrypt all LAN traffic (if gateway is necessary)

If you must use a shared gateway (for devices that can't run their own client), encrypt inter-device traffic so LAN monitoring reveals nothing useful:

Device A ──WireGuard──▶ Gateway ──VLESS──▶ VPS ──▶ Internet
Device B ──WireGuard──▶ Gateway ──┘
  • Run WireGuard between every client device and the gateway (not just to the VPS)
  • The LAN observer sees: encrypted WireGuard UDP between devices - no content, no destination correlation
  • The gateway's external traffic is VLESS (looks like HTTPS) - no fan-in pattern visible at the protocol level

WireGuard peer config on client devices:

[Interface]
PrivateKey = <CLIENT_PRIVATE_KEY>
Address = 10.99.0.2/24

[Peer]
PublicKey = <GATEWAY_PUBLIC_KEY>
AllowedIPs = 0.0.0.0/0    # Route all traffic to gateway
Endpoint = 192.168.1.50:51820    # Gateway LAN IP
PersistentKeepalive = 45

The gateway then routes this through its VLESS tunnel (Section 9.2).

Limitation: The LAN observer still sees that multiple devices are sending encrypted traffic to one device. The volume correlation is reduced (WireGuard adds its own packet sizing) but the fan-in pattern is still partially visible.

Strategy 3: Traffic volume normalization (reduce anomalies)

If the gateway approach is unavoidable and WireGuard between devices is impractical:

  • Bandwidth limiting: Cap the tunnel throughput to match the gateway device's normal traffic volume. If the device normally generates 500 KB/s of traffic, don't let the tunnel exceed that.
# tc (traffic control) rate limit on the tunnel interface
sudo tc qdisc add dev sb-tun0 root tbf rate 4mbit burst 32kbit latency 50ms
  • Split tunneling: Only route sensitive traffic (messaging, specific sites) through the tunnel. Let bulk traffic (streaming, updates) go direct. This keeps the traffic volume and pattern closer to normal.

In sing-box, use routing rules to only proxy specific domains:

{
  "route": {
    "rules": [
      { "domain_suffix": [ "signal.org", "telegram.org", "element.io" ], "outbound": "proxy" },
      { "domain_keyword": [ "news", "bbc", "reuters" ], "outbound": "proxy" }
    ],
    "default_outbound": "direct"
  }
}
  • Schedule diversity: Don't run the tunnel 24/7 if the device normally sleeps at night. Match tunnel activity to the device's normal usage patterns.

Strategy 4: Eliminate LAN observability (physical layer)

If the LAN itself is the threat (compromised router, shared network):

  • Direct Ethernet to WAN: Bypass the LAN entirely. Connect the tunnel device directly to the ISP modem (if separate from router), or use a dedicated VLAN that doesn't pass through the monitored switch.
  • Separate physical network: Use a mobile hotspot or secondary ISP connection for the tunnel device. The primary LAN never sees tunnel traffic.
  • WiFi isolation: If using WiFi, enable AP isolation (client isolation) on the access point so devices can't see each other's traffic. This doesn't hide traffic from the router itself but prevents other devices from sniffing.

10.12.3 Internal Multi-Protocol Traffic Fingerprint#

The previous strategies focus on the fan-in pattern (many devices -> one gateway) and the outbound (gateway -> VPS). But there's a separate detection surface: the protocol diversity of traffic flowing between LAN devices and the gateway.

A normal device on the LAN talks to the router via HTTPS, DNS, and maybe streaming. A gateway device that proxies for others receives a mix of protocols from multiple sources that no normal device ever would:

Traffic Seen on LAN (gateway as destination)Normal for a PC?Normal for a Router?
SSH (port 22) from Device BNoNo
SMB/CIFS (port 445) from Device COnly if file sharing configuredNo
HTTPS from 5 different devices simultaneouslyNoYes - but the gateway is NOT the router
DNS queries from multiple devicesNoYes - but only if it's a DNS server
Mixed protocols from diverse MAC addressesNoOnly for a router/AP
RDP (port 3389) from Device DNoNo

The problem: the gateway device starts looking like a router, but it's not the router. Any LAN monitoring that sees a non-router device receiving traffic from multiple other devices on diverse ports flags this immediately as a proxy, pivot point, or rogue gateway.

Mitigations:

A) WireGuard encapsulation (eliminates protocol visibility)

This is Strategy 2 from above, but the key insight is: WireGuard between clients and gateway collapses all protocols into one - encrypted UDP on a single port. The LAN observer sees:

Device A -> 192.168.1.50:51820 (UDP, encrypted)
Device B -> 192.168.1.50:51820 (UDP, encrypted)
Device C -> 192.168.1.50:51820 (UDP, encrypted)

No SSH, no SMB, no HTTPS, no DNS - just encrypted UDP. The observer knows something is being tunneled but cannot see what. This is far less distinctive than mixed plaintext protocols.

To further reduce the fan-in signal, stagger WireGuard keepalives across devices (different PersistentKeepalive values: 30, 37, 45, 52...) so traffic doesn't arrive in synchronized bursts.

B) SOCKS proxy over SSH (single protocol, plausible deniability)

Instead of routing at the IP level, devices connect to the gateway via SSH with dynamic port forwarding:

# On each client device - creates a local SOCKS proxy via SSH to the gateway
ssh -D 1080 -N -f user@192.168.1.50

The LAN observer sees only SSH traffic between devices - plausible as normal admin/file-transfer activity. Apps on each device use localhost:1080 as SOCKS proxy. The gateway's sing-box/Xray TPROXY or TUN captures SSH'd traffic and routes it through VLESS.

Limitation: only TCP works through SOCKS. UDP (DNS, WireGuard, VoIP) needs separate handling.

C) HTTPS proxy (most plausible single-protocol cover)

Run a small HTTPS proxy (e.g., squid with TLS, or a simple mitmproxy in transparent mode) on the gateway. Devices configure it as their HTTP/HTTPS proxy:

Device A -> 192.168.1.50:8443 (HTTPS CONNECT)
Device B -> 192.168.1.50:8443 (HTTPS CONNECT)

The LAN observer sees: HTTPS to one device. This is plausible as a local cache/proxy, especially in offices or tech-savvy homes. The gateway's VLESS tunnel carries the actual traffic outbound.

Setup on gateway:

# (Arch)
pacman -S squid

# (Debian)
apt install -y squid
# /etc/squid/squid.conf (minimal, HTTPS CONNECT only)
http_port 192.168.1.50:3128
https_port 192.168.1.50:8443 cert=/etc/squid/gateway.pem key=/etc/squid/gateway.key

acl localnet src 192.168.1.0/24
http_access allow localnet
http_access deny all

# Forward all traffic through sing-box's SOCKS inbound
cache_peer 127.0.0.1 parent 8080 0 no-query default no-digest
never_direct allow all

Clients set https://192.168.1.50:8443 as proxy. The LAN sees only HTTPS to the gateway.

D) Per-device tunnels with NAT hairpin (zero LAN inter-device traffic)

The most paranoid approach: each device tunnels to the VPS independently (Strategy 1), and any inter-device communication also goes through the VPS and back:

Device A -> VLESS -> VPS -> Internet
Device B -> VLESS -> VPS -> Internet
Device A -> VLESS -> VPS -> NetBird -> VPS -> VLESS -> Device B

No device talks to any other device on the LAN. All inter-device traffic hairpins through the VPS via NetBird/WireGuard overlay. The LAN observer sees each device making independent HTTPS connections to different IPs (VPS or Cloudflare) - indistinguishable from normal browsing.

Cost: higher latency for inter-device communication (round-trip through VPS), more VPS bandwidth. But zero LAN-level signals.

10.12.4 Detection Difficulty Matrix#

ApproachFan-in Visible?Protocol Diversity?Volume Anomaly?Correlation RiskComplexity
Each device tunnels independentlyNoNo (each looks normal)Low (distributed)LowLow
Per-device tunnels + NAT hairpinNoNoLowNone (no LAN chatter)Medium
WireGuard mesh to gatewayPartiallyNo (single UDP port)MediumLow (timing obscured)Medium
HTTPS proxy on gatewayPartiallyNo (single HTTPS)MediumMediumLow
SSH SOCKS to gatewayPartiallyNo (single SSH)MediumMediumLow
Raw gateway NAT (no encryption)YesYes (SSH+SMB+HTTPS+DNS)HighHighLow
Rate-limited + split-tunnel gatewayYesYes (reduced)Low (capped)MediumMedium
Separate physical networkNoNoNoNoneHigh (hardware)

Bottom line: Never use a raw NAT gateway - the multi-protocol fan-in pattern is trivially detectable. At minimum, encapsulate all LAN-to-gateway traffic in a single protocol (WireGuard, SSH, or HTTPS proxy). Best: eliminate the gateway entirely with per-device independent tunnels.


11. Personal & Device Security (Life-Threatening Scenarios)#

When censorship bypass carries risk of arrest, imprisonment, or physical harm, the tunnel is only one part of the picture. The device, your identity trail, and the VPS itself are all attack surfaces.

11.1 Kill Switch (Tunnel Drop Behavior)#

What happens when the tunnel crashes?

  • sing-box TUN with strict_route: true: All traffic is dropped. No leak. This IS a kill switch. If sing-box crashes, the TUN interface disappears and strict_route ensures no traffic can bypass it.
  • Xray TPROXY (nftables): If Xray crashes, nftables rules remain active but the TPROXY target has no listening socket. Packets are silently dropped. This IS a kill switch by accident - but verify with curl --connect-timeout 5 https://ifconfig.me while Xray is stopped.
  • SOCKS/HTTP proxy mode (no TUN): No kill switch. Apps that ignore proxy settings leak directly. Never rely on proxy mode in high-risk scenarios.
  • Android VPN mode (v2rayNG/Hiddify): Android's VPN API blocks non-VPN traffic while the VPN is active. If the app crashes, traffic is blocked until the VPN reconnects - IF "Always-on VPN" and "Block connections without VPN" are enabled in Android Settings -> Network -> VPN.

Verify your kill switch works:

# Stop the tunnel
sudo systemctl stop sing-box@tunnel

# Test - this should timeout, NOT show your real IP
curl --connect-timeout 5 https://ifconfig.me
# Expected: connection refused or timeout

# Restart
sudo systemctl start sing-box@tunnel

11.2 IPv6 Leak Prevention (Client-Side)#

The sing-box configs in this guide use "strategy": "ipv4_only" and only assign an IPv4 TUN address. If your client has IPv6 connectivity, IPv6 traffic bypasses the tunnel entirely - a critical leak.

Option A: Disable IPv6 on the client (simplest):

# Linux - persist across reboots
cat > /etc/sysctl.d/99-no-ipv6.conf <<'EOF'
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
EOF
sysctl --system

Option B: Add IPv6 to the TUN (tunnel IPv6 too):

Update the sing-box TUN config:

"address": [ "172.19.0.1/30", "fdfe:dcba:9876::1/126" ]

And remove "strategy": "ipv4_only" from DNS (or change to "prefer_ipv4").

Option C: Block IPv6 at the firewall (belt + suspenders):

# Add to nftables
table ip6 block_ipv6 {
  chain output {
    type filter hook output priority 0; policy drop;
    oif lo accept
  }
}

Android: Settings -> WiFi -> [network] -> Advanced -> IPv6 -> disable. Or use sing-box TUN which captures both IPv4 and IPv6 when strict_route: true.

11.3 WebRTC Leak Prevention#

WebRTC (used for video calls, file sharing in browsers) can leak your real IP via STUN even through a VPN tunnel.

Firefox:

about:config -> media.peerconnection.enabled -> false

Chromium/Chrome/Edge: No native toggle. Install uBlock Origin and enable "Prevent WebRTC from leaking local IP addresses" in settings. Or use a dedicated extension like WebRTC Control.

Verify: Visit https://browserleaks.com/webrtc - should show only the VPS IP or "N/A".

strict_route: true in sing-box does NOT fully block WebRTC STUN on all platforms - STUN uses UDP which the TUN captures, but some WebRTC implementations use ICE candidates from local interfaces before the TUN, leaking private IPs.

QUIC/HTTP3 leak: Browsers using QUIC (UDP port 443) establish a separate UDP channel that may bypass SOCKS/HTTP proxy settings. In TUN mode this is captured, but in proxy-only mode (Section 7.5) QUIC completely bypasses the proxy.

Disable QUIC in browsers:

  • Chrome/Edge: chrome://flags/#enable-quic -> Disabled
  • Firefox: about:config -> network.http.http3.enable -> false

11.4 Timezone, Locale & Fingerprint Leaks#

A user connecting from a Finnish VPS but with a Russian timezone and Russian browser language creates a strong signal.

LeakWhat It RevealsFix
Intl.DateTimeFormat().resolvedOptions().timeZoneReal timezone (e.g. Europe/Moscow)Set TZ=Europe/Helsinki in shell; use browser timezone spoofing extension
navigator.language / Accept-LanguageReal locale (ru-RU, fa-IR)Change browser language to en-US
Date().getTimezoneOffset()UTC offsetOnly fixable with timezone spoofing
System clock skewNTP offset hints at real locationUse NTP server through the tunnel
Screen resolution / fontsFingerprint uniquenessUse Tor Browser (normalized fingerprint) for sensitive browsing

Practical fix: For sensitive browsing, use Tor Browser (even through the VLESS tunnel) - it normalizes timezone, locale, resolution, fonts, and canvas fingerprint automatically.

11.5 Physical Device Security#

MeasureWhyHow
Full disk encryptionSeized device reveals configs, keys, browsing historyLUKS (Linux), BitLocker (Windows), FileVault (macOS)
Strong screen lockShoulder surfing, brief confiscation8+ digit PIN or biometric + short timeout (30s)
Emergency wipeDevice about to be seizedAndroid: panic button apps (Wasted, Duress); Linux: LUKS nuke key (cryptsetup-nuke-password)
Hidden volumesPlausible deniability under coercionVeraCrypt hidden volume (two passwords: decoy + real)
SIM removalPrevent cell tower tracking when not using tunnelRemove SIM or airplane mode when using WiFi-only tunnel
MAC randomizationWiFi MAC identifies your device across locationsLinux: macchanger -r wlan0 before connecting; Android/iOS: enabled by default on modern versions
Separate deviceYour daily phone has personal data, contacts, photosUse a dedicated cheap phone or laptop for censorship bypass only

11.6 VPS Seizure Threat Model#

If authorities seize your VPS, what can they recover?

ArtifactWhat It RevealsMitigation
Xray config.jsonAll client UUIDs, Reality private key, Short IDsEncrypt VPS disk (LUKS); use RAM-disk for config if provider supports
nginx access logsConnection timestamps, Cloudflare IPs (CDN), real IPs (Reality)Disable access logging: access_log off; in nginx
systemd journalService start/stop times, error messagesStorage=volatile in /etc/systemd/journald.conf (logs in RAM only)
Xray logsConnection metadata even at warning level"loglevel": "none" in production
Shell historyCommands you ran on the VPSunset HISTFILE or HISTSIZE=0 in shell profile
SSH authorized_keysYour SSH public key (linkable to other servers)Use a dedicated key pair for this VPS only
Payment trailLinks your identity to the VPSPay with cryptocurrency (Monero preferred) or prepaid cards
Domain registrationWHOIS links to your emailUse WHOIS privacy; use a throwaway email for domain registration

Hardened VPS setup:

# Disable all persistent logging
sed -i 's/^#\?Storage=.*/Storage=volatile/' /etc/systemd/journald.conf
systemctl restart systemd-journald

# Disable nginx access logs globally (add to http {} block)
# Do NOT delete existing access_log lines blindly - add override in http {}
sed -i '/^http {/a\    access_log off;' /etc/nginx/nginx.conf
# Also disable error log detail
sed -i '/^http {/a\    error_log /dev/null;' /etc/nginx/nginx.conf
nginx -t && systemctl reload nginx

# Disable shell history
echo 'unset HISTFILE' >> /etc/profile
echo 'HISTSIZE=0' >> /etc/profile

# Set Xray log to none
# In config.json: "log": { "loglevel": "none" }

11.7 Mobile App Forensics#

Forensic tools can recover evidence of censorship bypass tools even after uninstall:

TraceSurvives Uninstall?Mitigation
Package name in Android pm listNo (removed on uninstall)Use Android Work Profile (Shelter/Island app) to isolate
App data (/data/data/com.v2ray.ang/)No (removed on uninstall)Clear data before uninstalling
APK download historyYes (browser history, file manager cache)Download APKs via Tor Browser; delete from Downloads
Recent apps / usage statsYes (survives uninstall for ~30 days)Android: Settings -> Apps -> Usage Access -> clear stats
Google Play install historyYes (synced to Google account)Sideload APKs instead of using Play Store
VPN connection logsYes (Android Settings -> VPN shows configured VPNs)Remove VPN config before uninstalling app

Most secure approach: Use a dedicated device or Android Work Profile. Never install bypass tools on a device linked to your real Google/Apple account.


12. Risk Assessment & Method Ranking#

Overall Best (Performance + Resilience)#

RankMethodStealthSpeedComplexityBest For
1VLESS+Reality+Vision (direct)Very HighExcellent (~30ms)MediumFixed-line ISPs, primary daily use
2VLESS+gRPC+CDN (Cloudflare)ExtremeGood (~80-120ms)MediumFallback, mobile CIDR whitelist
3Two-hop chain (domestic->foreign)Very HighGood (~60ms)HighMobile CIDR whitelist, no CDN
4Home server bridge + WireGuardHighDepends on homeMediumMobile lockdown, home broadband works
5wstunnel + WireGuard (WS tunnel)HighGoodMediumUDP-blocked environments

Most Incognito (Plausible Deniability, Life-Threatening Situations)#

RankMethodWhy It's DeniableLimitation
1VLESS+Reality behind a real websiteVPS looks like a normal web server; no VPN software on server; traffic looks like visiting samsung.comRequires VPS you control
2CDN via throwaway domainTraffic goes to Cloudflare IPs; your domain is cheap disposable; VPS IP never exposedCloudflare account linked to email
3wstunnel over HTTPSLooks like normal WebSocket traffic to a website; wstunnel binary can be renamedWebSocket has identifiable upgrade header
4SSH tunnel (-D SOCKS)Standard SSH; plausible as server admin work; no special softwareSSH itself may be flagged
5Briar/Meshtastic meshZero internet, zero server, purely localVery limited bandwidth and range

For life-threatening situations:

  • Use a separate device for censorship bypass - not your daily phone
  • The VPS should have zero connection to your real identity (paid with crypto, registered with throwaway email)
  • No VPN apps visible on your phone - use the home bridge or OPNsense gateway approach so your phone only runs WireGuard/NetBird (legitimate enterprise VPN tools)
  • Pre-install Briar for mesh messaging as absolute last resort
  • Keep a wstunnel binary (renamed to something innocuous like healthcheck) on a hidden partition - it's a single static binary with no installation footprint

13. Optional Enhancements#

13.1 Keep Existing wstunnel WebSocket Path#

Your current location ^~ /v1/a9f0b1c2d3e4f5 { ... } stays inside the web backend (now on 127.0.0.1:8443) and continues to work.

wstunnel as WebSocket alternative: If you prefer wstunnel over Xray's built-in WebSocket transport for the CDN path, you can run a wstunnel server on the VPS alongside Xray. wstunnel is a single static Rust binary that tunnels TCP/UDP over WebSocket - it's simpler to audit and has no Xray dependency. Point the wstunnel client at the Cloudflare-proxied domain, and have wstunnel forward to Xray's local VLESS port. This adds a layer of indirection but also means the WebSocket traffic patterns come from wstunnel's implementation rather than Xray's (different fingerprint).

13.2 Real Client IP Logs (PROXY Protocol)#

When stream is in front of HTTPS vhosts, 8443 sees clients as 127.0.0.1. Fix with PROXY protocol:

In /etc/nginx/stream-enabled/00-443-sni-mux.conf, route web traffic through an internal hop:

upstream web_tls  { server 127.0.0.1:8444; }
upstream xray_tls { server 127.0.0.1:4443; }

Add the PROXY protocol forwarder:

server {
  listen 127.0.0.1:8444;
  proxy_protocol on;
  proxy_pass 127.0.0.1:8443;
}

In every 8443 vhost, add proxy_protocol (keep existing flags):

listen 127.0.0.1:8443 ssl http2 proxy_protocol default_server;

In http {} block:

real_ip_header proxy_protocol;
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
real_ip_recursive on;

Once enabled, direct connections to 127.0.0.1:8443 without PROXY protocol will fail. Always test through :443.

Loopback caveat: Any local service connecting to 127.0.0.1:8443 (ACME challenges, health checks, the server's own NetBird client) will fail because it sends no PROXY protocol header. If the server also runs a NetBird client connecting to its own management server, create a dedicated loopback vhost on a separate port (e.g., 127.0.0.1:8445) that terminates TLS and proxies gRPC/WebSocket directly to the management backend without PROXY protocol. Route it via a separate $backend_lo map in the stream block's loopback server.

13.3 Auto-Start sing-box on Linux (systemd)#

Some distributions ship a sing-box@.service template unit. If yours doesn't (Arch sing-box package ships only sing-box.service), create the template:

# /etc/systemd/system/sing-box@.service
[Unit]
Description=sing-box (%i profile)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/sing-box run -c /etc/sing-box/%i.json
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW

[Install]
WantedBy=multi-user.target

Place configs as named profiles:

sudo install -d -m 750 /etc/sing-box
sudo install -m 640 /path/to/tunnel.json /etc/sing-box/tunnel.json

sudo systemctl daemon-reload
sudo systemctl enable --now sing-box@tunnel

Multiple profiles can run simultaneously:

sudo systemctl enable --now sing-box@home
sudo systemctl enable --now sing-box@work

The profile name (tunnel, home, work) maps to /etc/sing-box/<name>.json.

13.4 3X-UI Web Panel#

For those who prefer a GUI over manual JSON. Manages multiple users, traffic stats, expiry.

bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)

After install:

  1. Access at http://YOUR_VPS_IP:2053 (change port immediately)
  2. Change default credentials
  3. Add inbound -> VLESS -> Reality or WS+TLS
  4. Panel generates client configs and QR codes automatically

Warning: The panel is a fingerprint. Restrict access by IP or only access via SSH tunnel: ssh -L 2053:127.0.0.1:2053 user@vps


14. Troubleshooting#

SymptomCheckFix
archworks.co broken after setupnginx -tConfirm vhosts moved to 127.0.0.1:8443
https://VPS_IP shows wrong siteHomepage vhost not default_serverAdd default_server to 8443 listen
VLESS Reality won't connectClient SNI, server shortIds, Xray on 4443Verify all params match exactly
CDN: 502 Bad GatewayXray WS inbound not runningsystemctl status xray, check port 10086
CDN: 521 Web Server DownNginx not listening on 443ss -tlnp | grep 443
CDN works briefly then stallsCloudflare WebSocket disabledDashboard -> Network -> WebSockets ON
Reality works then throttledISP TLS policing (net4people #546)Switch to CDN fallback
No connection at allVPS IP blockedcurl -x socks5://127.0.0.1:1080 https://ifconfig.me - if fails, try CDN or new VPS IP
sing-box routing loopServer IP going through tunnelAdd server domain to direct route rule
WireGuard/NetBird won't start over tunnelTwo full-tunnel VPNs conflictingUse specific AllowedIPs, not 0.0.0.0/0 for WG
ACME HTTP-01 failsCloudflare proxy intercepts challengeUse DNS-01 or Cloudflare Origin Certificates
Nginx unknown directive "stream"Stream module missingInstall libnginx-mod-stream (Debian) or check include modules (Arch)
Arch xray.service fails on geoip.datMissing assetsUse minimal config (no GeoIP rules) or install assets below
DNS leaking real IPSystem resolver bypassing tunnelEnable DoH in sing-box, verify at ipleak.net
CDN: Cloudflare returns 403ECH enabled on Cloudflare zoneDisable ECH via API (Section 6.3)
CDN gRPC: connection resetCloudflare gRPC not enabledDashboard -> Network -> gRPC ON
Xray failed to read client helloISP modifying ClientHello (Xray-core #5332)Switch to CDN path or non-443 port
TPROXY: only first UDP packet worksXray UDP TPROXY bug (#4791)Update Xray to v26.2.6+ or use sing-box TUN
chrome_pq fingerprint failsPost-quantum fingerprint broken with RealityUse chrome (not chrome_pq)
Mobile: no connection at allCIDR whitelist blocking foreign IPsUse CDN (Cloudflare IPs whitelisted) or two-hop chain (Section 10.9)
Android: can't run VLESS + WireGuardAndroid single-VPN limitationUse home bridge (10.10) or rooted setup (9.4)

Fix geoip.dat / geosite.dat Errors (Arch)#

If using routing rules with geoip:* / geosite:*:

sudo pacman -S --needed v2ray-geoip v2ray-domain-list-community

GEOIP="$(pacman -Ql v2ray-geoip | awk '/geoip\.dat$/ {print $2; exit}')"
GEOSITE="$(pacman -Ql v2ray-domain-list-community | awk '/geosite\.dat$/ {print $2; exit}')"

sudo ln -sf "$GEOIP" /usr/bin/geoip.dat
sudo ln -sf "$GEOSITE" /usr/bin/geosite.dat

sudo systemctl restart xray

15. References#


See Also#